跳转至

下单结算

客户端展示下单结算界面展示

views/Cart.vue,代码:

<div class="li-3"><router-link to="/order" class="btn">去结算</router-link></div>

router/index.js,代码:

// 路由列表
const routes = [
    {
      meta:{
        title: "luffy2.0-首页",
        keepAlive: true
      },
      path:'/',         // uri访问地址
      component: ()=> import("../views/Home.vue")
    },{
      meta:{
        title: "登录",
        keepAlive: true
      },
      path: '/login',
      name: "Login",            // 路由名称
      component: ()=> import("../views/Login.vue"),         // uri绑定的组件页面
    },{
      meta:{
        title: "注册",
        keepAlive: true
      },
      path: '/register',
      name: "Register",            // 路由名称
      component: ()=> import("../views/Register.vue"),         // uri绑定的组件页面
    },{
      meta:{
        title: "项目课",
        keepAlive: true
      },
      path: '/project',
      name: "Course",            // 路由名称
      component: ()=> import("../views/Course.vue"),         // uri绑定的组件页面
    },{
      meta:{
        title: "项目课",
        keepAlive: true
      },
      path: '/project/:id',
      name: "Info",
      component: ()=> import("../views/Info.vue"),
    },{
      meta:{
        title: "购物车",
        keepAlive: true
      },
      path: '/cart',
      name: "Cart",
      component: ()=> import("../views/Cart.vue"),
    },{
      meta:{
        title: "确认下单",
        keepAlive: true
      },
      path: '/order',
      name: "Order",
      component: ()=> import("../views/Order.vue"),
    }
]

views/Order.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>
          <div class="right">
            <div class="">
              <span class="left"><router-link class="myorder-history" to="/cart">返回购物车</router-link></span>
            </div>
          </div>
        </div>
      </div>
      <div class="cart-body" id="cartBody">
        <div class="cart-body-title"><p class="item-1 l">课程信息</p></div>
        <div class="cart-body-table">
          <div class="item">
              <div class="item-2">
                  <a href="" class="img-box l"><img src="../assets/course-9.png"></a>
                  <dl class="l has-package">
                    <dt>【实战课程】3天Typescript精修 </dt>
                    <p class="package-item">减免价</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price"><em>¥</em><span>998.00</span></p>
                      <p class="original-price"><em>¥</em><span>800.00</span></p>
                  </div>
              </div>
          </div>
          <div class="item">
              <div class="item-2">
                  <a href="" class="img-box l"><img src="../assets/course-9.png"></a>
                  <dl class="l has-package">
                    <dt>【实战课程】3天Typescript精修 </dt>
                    <p class="package-item">减免价</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price"><em>¥</em><span>998.00</span></p>
                      <p class="original-price"><em>¥</em><span>800.00</span></p>
                  </div>
              </div>
          </div>
          <div class="item">
              <div class="item-2">
                  <a href="" class="img-box l"><img src="../assets/course-9.png"></a>
                  <dl class="l has-package">
                    <dt>【实战课程】3天Typescript精修 </dt>
                    <p class="package-item">减免价</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price"><em>¥</em><span>998.00</span></p>
                      <p class="original-price"><em>¥</em><span>800.00</span></p>
                  </div>
              </div>
          </div>
          <div class="item">
              <div class="item-2">
                  <a href="" class="img-box l"><img src="../assets/course-9.png"></a>
                  <dl class="l has-package">
                    <dt>【实战课程】3天Typescript精修 </dt>
                    <p class="package-item">减免价</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price"><em>¥</em><span>998.00</span></p>
                      <p class="original-price"><em>¥</em><span>800.00</span></p>
                  </div>
              </div>
          </div>
          <div class="item">
              <div class="item-2">
                  <a href="" class="img-box l"><img src="../assets/course-9.png"></a>
                  <dl class="l has-package">
                    <dt>【实战课程】3天Typescript精修 </dt>
                    <p class="package-item">减免价</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price"><em>¥</em><span>998.00</span></p>
                      <p class="original-price"><em>¥</em><span>800.00</span></p>
                  </div>
              </div>
          </div>
        </div>
        <div class="coupons-box">
          <div class="coupon-title-box">
            <p class="coupon-title">
              使用优惠券/积分
                <span v-if="state.use_coupon" @click="state.use_coupon=!state.use_coupon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" data-v-394d1fd8=""><path fill="currentColor" d="M831.872 340.864 512 652.672 192.128 340.864a30.592 30.592 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.592 30.592 0 0 0-42.752 0z"></path></svg></span>
                <span v-else @click="state.use_coupon=!state.use_coupon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" data-v-394d1fd8=""><path fill="currentColor" d="m488.832 344.32-339.84 356.672a32 32 0 0 0 0 44.16l.384.384a29.44 29.44 0 0 0 42.688 0l320-335.872 319.872 335.872a29.44 29.44 0 0 0 42.688 0l.384-.384a32 32 0 0 0 0-44.16L535.168 344.32a32 32 0 0 0-46.336 0z"></path></svg></span>
<!--                <i :class="state.use_coupon?'el-icon-arrow-up':'el-icon-arrow-down'" @click="state.use_coupon=!state.use_coupon"></i>-->
            </p>
          </div>
          <transition name="el-zoom-in-top">
          <div class="coupon-del-box" v-if="state.use_coupon">
            <div class="coupon-switch-box">
              <div class="switch-btn ticket" :class="{'checked': state.discount_type===0}" @click="state.discount_type=0">优惠券 (4)<em><i class="imv2-check"></i></em></div>
              <div class="switch-btn code" :class="{'checked': state.discount_type===1}" @click="state.discount_type=1">积分<em><i class="imv2-check"></i></em></div>
            </div>
            <div class="coupon-content ticket" v-if="state.discount_type===0">
              <p class="no-coupons" v-if="state.coupon_list.length<1">暂无可用优惠券</p>
              <div class="coupons-box" v-else>
               <div class="content-box">
                <ul class="nouse-box">
                 <li class="l">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥100 </p>
                    <p class="use-inst l">满499可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l select">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l wait-use">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l wait-use">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                </ul>
                <ul class="use-box">
                 <li class="l useing">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥100 </p>
                    <p class="use-inst l">满499可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                </ul>
                <ul class="overdue-box">
                 <li class="l useing">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥100 </p>
                    <p class="use-inst l">满499可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                </ul>
               </div>
              </div>
            </div>
            <div class="coupon-content code" v-else>
                <div class="input-box">
                  <el-input-number placeholder="10积分=1元" v-model="state.credit" :step="1" :min="0" :max="1000"></el-input-number>
                  <a class="convert-btn">兑换</a>
                </div>
                <div class="converted-box">
                  <p>使用积分:<span class="code-num">200</span></p>
                  <p class="course-title">课程:<span class="c_name">3天JavaScript入门</span>
                    <span class="discount-cash">100积分抵扣:<em>10</em>元</span>
                  </p>
                  <p class="course-title">课程:<span class="c_name">3天JavaScript入门</span>
                    <span class="discount-cash">100积分抵扣:<em>10</em>元</span>
                  </p>
                </div>
                <p class="error-msg">本次订单最多可以使用1000积分,您当前拥有200积分。(10积分=1元)</p>
                <p class="tip">说明:每笔订单只能使用一次积分,并只有在部分允许使用积分兑换的课程中才能使用。</p>
              </div>
          </div>
          </transition>
        </div>
        <div class="pay-type">
          <p class="title">选择支付方式</p>
          <div class="list">
            <img :src="state.pay_type==0?'/src/assets/alipay2.png':'/src/assets/alipay1.png'" @click="state.pay_type=0" alt="支付宝">
            <img :src="state.pay_type==1?'/src/assets/wechat2.png':'/src/assets/wechat1.png'" @click="state.pay_type=1" alt="微信">
            <img :src="state.pay_type==2?'/src/assets/yue2.png':'/src/assets/yue1.png'"  @click="state.pay_type=2" alt="余额">
          </div>
        </div>
        <div class="pay-box" :class="{fixed:state.fixed}">
                  <div class="row-bottom">
            <div class="row">
              <div class="goods-total-price-box">
                <p class="r rw price-num"><em>¥</em><span>1811.00</span></p>
                <p class="r price-text"><span>共<span>5</span>件商品,</span>商品总金额:</p>
              </div>
            </div>
            <div class="coupons-discount-box">
              <p class="r rw price-num">-<em>¥</em><span>60.00</span></p>
              <p class="r price-text">优惠券/积分抵扣:</p>
            </div>
            <div class="pay-price-box clearfix">
              <p class="r rw price"><em>¥</em><span id="js-pay-price">1751.00</span></p>
              <p class="r price-text">应付:</p>
            </div>
            <span class="r btn btn-red submit-btn">提交订单</span>
                    </div>
          <div class="pay-add-sign">
            <ul class="clearfix">
              <li>支持花呗</li>
              <li>可开发票</li>
              <li class="drawback">7天可退款</li>
            </ul>
          </div>
          </div>
      </div>
    </div>
    <Footer/>
  </div>
</template>

<script setup>
import {reactive,watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {useStore} from "vuex";

let store = useStore()

let state = reactive({
  course_list: [],     // 购物车中的商品课程列表
  total_price: 0,      // 勾选商品的总价格
  use_coupon: false,   // 用户是否使用优惠
  discount_type: 0,    // 0表示优惠券,1表示积分
  coupon_list:[1,2,3], // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券
  credit: 0,           // 当前用户选择抵扣的积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
})

// 监听用户选择的支付方式
watch(
    ()=>state.pay_type,
    ()=>{
      console.log(state.pay_type)
    }
)

// 底部订单总价信息固定浮动效果
window.onscroll = ()=>{
  let cart_body_table = document.querySelector(".cart-body-table")
  let offsetY = window.scrollY
  let maxY = cart_body_table.offsetTop+cart_body_table.offsetHeight
  state.fixed = offsetY < maxY
}
</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
}

.l {
  float: left;
}
.r {
  float: right;
}
.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
}

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{
    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 .item {
    height: 88px;
    padding: 24px 0;
    background: #f3f5f7;
}
.cart-body .cart-body-table {
    padding-bottom: 36px;
    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-1 {
    width: 120px
}

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

.cart-body .item-2 {
    width: 1020px;
  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;
    height: 46px;
    width: 96px;
  padding-top: 24px;
  padding-bottom: 24px;
  color: #f01414;
}
.cart-body .item-3 .price em,
.cart-body .item-3 .price span{
  font-size: 18px;
}
.cart-body .item-3 .price .original-price em,
.cart-body .item-3 .price .original-price span{
  font-size: 15px;
  color: #aaa;
  text-decoration: line-through;
}

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

.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 .price {
    font-size: 16px;
    color: #f01414;
    line-height: 24px;
    font-weight: 700
}

.coupons-box::after{
  display: block;
  content: "";
  overflow: hidden;
  clear: both;
}
.coupons-box .coupon-title-box {
    margin: 27px 0 0 12px
}

.coupons-box .coupon-title-box .coupon-title {
    color: #07111b;
    font-size: 16px;
    line-height: 34px
}

.coupons-box .coupon-title-box .coupon-title svg {
    position: relative;
    width: 26px;
    height: 26px;
    top: 5px;
    margin-left: 12px;
    font-size: 24px;
    color: #999;
    cursor: pointer
}


.coupons-box .coupon-del-box {
    width: 100%;
    padding-top: 24px;
    box-sizing: border-box
}

.coupons-box .coupon-del-box .coupon-switch-box {
    margin-bottom: 16px
}

.coupons-box .coupon-del-box .coupon-switch-box .switch-btn {
    position: relative;
    display: inline-block;
    width: 138px;
    height: 58px;
    line-height: 20px;
    border: 1px solid #d9dde1;
    border-radius: 8px;
    padding: 18px 0;
    color: #1c1f21;
    text-align: center;
    font-size: 16px;
    margin-right: 16px;
    box-sizing: border-box;
    cursor: pointer
}

.coupons-box .coupon-del-box .coupon-switch-box .switch-btn em {
    display: none;
    position: absolute;
    bottom: 0;
    right: 0;
    width: 0;
    height: 0;
    line-height: 54px;
    border-left-width: 20px;
    border-left-style: solid;
    border-left-color: transparent;
    border-bottom-width: 20px;
    border-bottom-style: solid;
    border-bottom-color: #f01414
}

.coupons-box .coupon-del-box .coupon-switch-box .switch-btn em i {
    color: #fff;
    position: absolute;
    bottom: -20px;
    right: 0;
    font-size: 12px
}

.coupons-box .coupon-del-box .coupon-switch-box .switch-btn.checked {
    border: 2px solid #f01414
}

.coupons-box .coupon-del-box .coupon-switch-box .switch-btn.checked em {
    display: block
}

.coupons-box .coupon-del-box .coupon-content {
    position: relative;
    background: #f3f5f7;
    border-radius: 8px;
    padding: 24px
}

.coupons-box .coupon-del-box .coupon-content:before {
    content: "";
    display: block;
    position: absolute;
    top: -7px;
    left: 62px;
    border-left: 12px solid transparent;
    border-right: 12px solid transparent;
    border-bottom: 7px solid #f3f5f7
}

.coupons-box .coupon-del-box .coupon-content.ticket li {
    padding-top: 8px;
    box-sizing: border-box;
    width: 320px;
    background-color: #fff6f0;
    cursor: pointer;
    margin: 12px
}

.coupons-box .coupon-del-box .coupon-content.ticket li .more-del-box {
    padding: 16px 22px 24px 22px;
    width: 100%;
    box-sizing: border-box;
    background-repeat: no-repeat
}

.coupons-box .coupon-del-box .coupon-content.ticket li .price-box {
    height: 32px;
    line-height: 32px
}

.coupons-box .coupon-del-box .coupon-content.ticket li .price-box .price {
    font-size: 30px;
    margin-right: 4px
}

.coupons-box .coupon-del-box .coupon-content.ticket li .price-box .price sub {
    font-size: 24px;
    letter-spacing: -5px
}

.coupons-box .coupon-del-box .coupon-content.ticket li .price-box .use-inst {
    font-size: 12px;
    margin-top: 5px;
}

.coupons-box .coupon-del-box .coupon-content.ticket .active .price,
.coupons-box .coupon-del-box .coupon-content.ticket .active .use-inst {
    color: #fff
}

.coupons-box .coupon-del-box .coupon-content.ticket .active i {
    position: absolute;
    top: 12px;
    right: 12px;
    color: #fff;
    font-size: 24px
}

.coupons-box .coupon-del-box .coupon-content.ticket .no-coupons {
    font-size: 14px;
    color: #4d555d;
    line-height: 14px
}

.coupons-box .coupon-del-box .coupon-content.code {
    padding-left: 38px
}

.coupons-box .coupon-del-box .coupon-content.code:before {
    left: 216px
}

.coupons-box .coupon-del-box .coupon-content.code .input-box {
    position: relative;
    left: -12px;
    margin-top: 12px
}

.coupons-box .coupon-del-box .coupon-content.code .input-box .convert-input {
    background: #fff;
    border: 1px solid #9199a1;
    width: 356px;
    height: 48px;
    border-radius: 8px;
    font-size: 16px;
    font-weight: 600;
    color: #07111b;
    letter-spacing: 2px;
    line-height: 24px;
    padding: 12px 16px;
    box-sizing: border-box;
    vertical-align: middle
}

.coupons-box .coupon-del-box .coupon-content.code .input-box .convert-btn {
    display: inline-block;
    width: 124px;
    height: 48px;
    line-height: 22px;
    font-size: 16px;
    color: #fff;
    padding: 12px;
    background: #f01414;
    border-radius: 8px;
    margin-left: 24px;
    box-sizing: border-box;
    text-align: center;
    cursor: pointer
}

.coupons-box .coupon-del-box .coupon-content.code .converted-box p {
    line-height: 24px;
    font-size: 16px;
    color: #07111b;
  margin-top: 10px;
}

.coupons-box .coupon-del-box .coupon-content.code .converted-box .c_name,
.coupons-box .coupon-del-box .coupon-content.code .converted-box .code-num {
    padding-left: 8px
}

.coupons-box .coupon-del-box .coupon-content.code .converted-box .cancel-btn {
    background: #fff;
    border: 1px solid #d9dde1;
    line-height: 20px;
    padding: 2px 12px;
    text-align: center;
    border-radius: 4px;
    color: #f01414;
    font-size: 14px;
    margin-left: 16px;
    cursor: pointer
}

.coupons-box .coupon-del-box .coupon-content.code .converted-box .course-title {
    font-size: 14px;
    color: #07111b;
    font-weight: 600;
    margin-top: 12px
}

.coupons-box .coupon-del-box .coupon-content.code .converted-box .course-title .discount-cash {
    margin-left: 12px;
    color: #f01414
}

.coupons-box .coupon-del-box .coupon-content.code .error-msg {
    font-size: 14px;
    color: #f01414;
    margin-top: 8px;
    line-height: 20px;
    height: 20px
}

.coupons-box .coupon-del-box .coupon-content.code .tip {
    font-size: 14px;
    color: #93999f;
    margin-top: 8px;
    line-height: 20px
}


.coupons-box .content-box ul {
    width: 100%
}
.coupons-box .content-box .nouse-box::after,
.coupons-box .content-box .overdue-box::after,
.coupons-box .content-box .use-box::after {
  display: block;
  content: "";
  overflow: hidden;
  clear: both;
}
.coupons-box .content-box .nouse-box li,
.coupons-box .content-box .overdue-box li,
.coupons-box .content-box .use-box li {
    position: relative;
    padding: 24px 32px;
    margin-right: 16px;
    margin-bottom: 16px;
    width: 320px;
    height: 144px;
    border-radius: 8px;
    box-sizing: border-box;
    background-color: #fff;
    box-shadow: 0 8px 16px 0 rgba(7,17,27, .2);
    background-repeat: no-repeat;
    background-size: 320px 144px;
}
.coupons-box .content-box .nouse-box li.select{
  background-color: orangered;
}
.coupons-box .content-box .nouse-box li .detail-box,
.coupons-box .content-box .overdue-box li .detail-box,
.coupons-box .content-box .use-box li .detail-box {
    width: 100%;
    height: 100%
}

.coupons-box .content-box .nouse-box li .detail-box .price-box,
.coupons-box .content-box .overdue-box li .detail-box .price-box,
.coupons-box .content-box .use-box li .detail-box .price-box {
    margin-bottom: 8px;
    height: 40px;
    color: #93999f;
    line-height: 40px;
    font-weight: 700
}

.coupons-box .content-box .nouse-box li .detail-box .price-box .coupon-price,
.coupons-box .content-box .overdue-box li .detail-box .price-box .coupon-price,
.coupons-box .content-box .use-box li .detail-box .price-box .coupon-price {
    margin-right: 12px;
    font-size: 36px;
  margin-top: 5px;
}

.coupons-box .content-box .nouse-box li .detail-box .price-box .use-inst,
.coupons-box .content-box .overdue-box li .detail-box .price-box .use-inst,
.coupons-box .content-box .use-box li .detail-box .price-box .use-inst {
    font-size: 14px
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box,
.coupons-box .content-box .use-box li .detail-box .use-detail-box {
    font-size: 12px;
    color: #93999f;
    line-height: 24px
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box .use-ajust-box,
.coupons-box .content-box .use-box li .detail-box .use-detail-box .use-ajust-box {
    position: relative
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box i,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box .use-ajust-box i,
.coupons-box .content-box .use-box li .detail-box .use-detail-box .use-ajust-box i {
    position: relative;
    top: 3px;
    left: 0;
    font-size: 16px;
    color: #93999f;
    line-height: 24px;
    cursor: pointer
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box .use-course a,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box .use-ajust-box .use-course a,
.coupons-box .content-box .use-box li .detail-box .use-detail-box .use-ajust-box .use-course a {
    padding: 16px 0;
    width: 100%;
    display: block;
    font-size: 12px;
    color: #4d555d;
    line-height: 20px;
    border-bottom: 1px solid #d9dde1;
    box-sizing: border-box
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box .use-course a:hover,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box .use-ajust-box .use-course a:hover,
.coupons-box .content-box .use-box li .detail-box .use-detail-box .use-ajust-box .use-course a:hover {
    color: #07111b
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box .use-course a:last-child,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box .use-ajust-box .use-course a:last-child,
.coupons-box .content-box .use-box li .detail-box .use-detail-box .use-ajust-box .use-course a:last-child {
    border-bottom: none
}

.coupons-box .content-box li {
    background-image: url(/src/assets/coupons_bg.png)
}

.coupons-box .content-box .nouse-box li .detail-box .price-box .coupon-price {
    color: #f01414
}

.coupons-box .content-box .nouse-box li .detail-box .price-box .use-inst {
    color: #f01414
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box {
    color: #07111b
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box i {
    color: #4d555d
}

.coupons-box .content-box .nouse-box li.wait-use {
    background-image: url(/src/assets/coupon_start_bg.png)
}

.coupons-box .content-box .use-box li {
    background-image: url(/src/assets/coupons_used_bg.png)
}

.coupons-box .content-box .use-box li.useing {
    background-image: url(/src/assets/coupon_useing_bg.png)
}

.coupons-box .content-box .overdue-box li {
    background-image: url(/src/assets/coupons_overdue.png)
}

.tip-box ol {
    margin-top: 16px;
    width: 100%;
    list-style: decimal;
    margin-left: 14px;
    box-sizing: border-box
}

.tip-box ol li {
    font-size: 12px
}

.pay-box {
    margin-top: 36px;
    position: relative
}

.pay-box::after,
.goods-total-price-box::after,
.package-discount-box::after,
.pay-price-box::after,
.coupons-discount-box::after{
  display: block;
  content: "";
  clear: both;
  overflow: hidden;
}

.pay-box .rw {
    width: 140px;
    box-sizing: border-box;
    text-align: right
}

.pay-box .bargain-discount-box,.pay-box .coupons-discount-box,.pay-box .goods-total-price-box,.pay-box .package-discount-box,.pay-box .redpackage-discount-box,.pay-box .student-discount-box {
    margin-bottom: 12px;
    line-height: 26px
}

.pay-box .bargain-discount-box .price-num,.pay-box .coupons-discount-box .price-num,.pay-box .goods-total-price-box .price-num,.pay-box .package-discount-box .price-num,.pay-box .redpackage-discount-box .price-num,.pay-box .student-discount-box .price-num {
    position: relative;
    font-size: 14px;
    color: #07111b
}

.pay-box .bargain-discount-box .price-text,.pay-box .coupons-discount-box .price-text,.pay-box .goods-total-price-box .price-text,.pay-box .package-discount-box .price-text,.pay-box .redpackage-discount-box .price-text,.pay-box .student-discount-box .price-text {
    text-align: right;
    font-size: 14px;
    color: #07111b
}

.pay-box .bargain-discount-box .price-text span,.pay-box .coupons-discount-box .price-text span,.pay-box .goods-total-price-box .price-text span,.pay-box .package-discount-box .price-text span,.pay-box .redpackage-discount-box .price-text span,.pay-box .student-discount-box .price-text span {
    margin-left: 4px;
    margin-right: 4px
}

.pay-box .pay-add-sign {
    text-align: right;
    position: absolute;
    top: -10px
}

.pay-box .pay-add-sign li {
    float: left;
    padding: 0 12px;
    height: 26px;
    line-height: 26px;
    border: 1px solid #f01414;
    border-radius: 18px;
    font-size: 12px;
    color: #f01414;
    margin-right: 15px
}

.pay-box .pay-add-sign li.drawback {
    position: relative
}

.pay-box .pay-add-sign li.drawback .imv2-ques {
    position: absolute;
    top: -4px;
    right: -2px;
    background: #fff;
    color: #d7dbdf;
    font-size: 14px;
    display: inline-block;
    width: 14px;
    height: 14px;
    cursor: pointer
}

.pay-box .pay-add-sign li.drawback .imv2-ques:hover {
    color: #f20d0d
}

.pay-box .pay-add-sign a.checkbackbtn {
    display: none;
    color: #fff;
    font-size: 12px;
    text-align: center;
    border-radius: 8px;
    vertical-align: top;
    position: absolute;
    left: 100%;
    top: -12px;
    background: rgba(28,31,33,.25);
    width: 100px;
    height: 26px;
    line-height: 26px;
    margin-left: 8px
}

.pay-box .pay-add-sign a.checkbackbtn i.arrow {
    width: 0;
    height: 0;
    border-top: 5px solid transparent;
    border-right: 5px solid;
    border-bottom: 5px solid transparent;
    position: absolute;
    left: -5px;
    top: 8px;
    border-right-color: rgba(28,31,33,.25)
}

.pay-box .pay-price-box {
    color: #07111b
}

.pay-box .pay-price-box .price {
    position: relative;
    color: #f01414;
    font-size: 24px;
    font-weight: 700;
  line-height: 36px;
  height: 36px;
}
.pay-box .pay-price-box .price-text{
  line-height: 36px;
  height: 36px;
}
.pay-box .pay-price-box .price span {
    float: none;
    font-weight: 700
}

.pay-box .pay-account {
    font-size: 12px;
    color: #93999f;
    line-height: 24px;
    margin-bottom: 20px;
    margin-top: 15px
}

.pay-box .submit-btn {
    padding: 0;
    width: 140px;
    height: 40px;
    margin-top: 12px;
    text-align: center;
    font-size: 14px;
    line-height: 40px;
    border-radius: 24px
}

.pay-box .disabled {
    background: #ccc;
    cursor: not-allowed;
    border: none
}

.pay-box .presale-wrap {
    text-align: right
}

.pay-box .presale-wrap .submit-btn {
    margin-top: 24px
}

.pay-box .presale-box {
    display: inline-block;
    font-size: 0;
    text-align: left
}

.pay-box .presale-box .step {
    width: 213px;
    padding-bottom: 10px;
    position: relative
}

.pay-box .presale-box .step .title {
    font-size: 14px;
    color: #07111b;
    line-height: 26px
}

.pay-box .presale-box .step .title .price {
    color: #93999f;
    float: right
}

.pay-box .presale-box .step .title .price.active {
    color: #f01414
}

.pay-box .presale-box .step .desc {
    font-size: 12px;
    color: #93999f;
    line-height: 16px
}

.pay-box .presale-box .step:nth-child(3) .price {
    color: #f01414;
    font-size: 24px;
    font-weight: 700
}

.pay-box .presale-box .step .step-line {
    position: absolute;
    top: 8px;
    left: -16px;
    width: 9px;
    display: flex;
    flex-direction: column;
    align-items: center
}

.pay-box .presale-box .step .step-line .circle {
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background: rgba(147,153,159,.3)
}

.pay-box .presale-box .step .step-line .circle.active {
    background: #f01414
}

.pay-box .presale-box .step .step-line .line {
    height: 43px;
    border-left: 1px dashed rgba(147,153,159,.3)
}

.pay-box .presale-box .step .step-line .line.short {
    height: 27px
}

.pay-box.fixed {
    position: fixed;
    bottom: 0;
    left: 0;
    width: 100%;
    height: 80px;
    line-height: 80px;
    background-color: #fff;
    z-index: 300;
    box-shadow: 10px -2px 12px rgba(7,17,27,.2);
  padding-top: 10px;
}

.pay-box.fixed .row-bottom {
    max-width: 1500px;
    position: relative;
    margin: 0 auto;
}

.pay-box.fixed .row-bottom .row {
    float: left
}

.pay-box.fixed .row-bottom .bargain-discount-box,.pay-box.fixed .row-bottom .coupons-discount-box,.pay-box.fixed .row-bottom .js-total-hide,.pay-box.fixed .row-bottom .package-discount-box {
    display: none
}

.pay-box.fixed .bargain-discount-box,.pay-box.fixed .coupons-discount-box,.pay-box.fixed .goods-total-price-box,.pay-box.fixed .package-discount-box,.pay-box.fixed .pay-add-sign,.pay-box.fixed .pay-price-box,.pay-box.fixed .redpackage-discount-box {
    float: left;
    margin-bottom: 0
}

.pay-box.fixed .coupons-discount-box,.pay-box.fixed .package-discount-box,.pay-box.fixed .redpackage-discount-box {
    margin-left: 20px
}

.pay-box.fixed .goods-total-price-box {
    width: auto
}

.pay-box.fixed .rw {
    text-align: left;
    width: auto
}

.pay-box.fixed .price,.pay-box.fixed .price-num,.pay-box.fixed .price-text {
    line-height: 80px
}

.pay-box.fixed .pay-add-sign {
    position: static!important;
    margin-left: 20px
}

.pay-box.fixed .pay-add-sign li {
    float: left;
    padding: 0 12px;
    height: 26px;
    line-height: 26px;
    border: 1px solid #f01414;
    border-radius: 18px;
    font-size: 12px;
    color: #f01414;
    margin: 27px 20px 27px 0
}

.pay-box.fixed .pay-price-box {
    width: auto;
    margin-left: 20px
}

.pay-box.fixed .submit-btn {
    margin-top: 16px;
    width: 148px;
    height: 48px;
    line-height: 48px;
    font-size: 16px;
    border-radius: 24px
}

.pay-box.fixed .presale-wrap {
    float: left;
    text-align: left
}

.pay-box.fixed .presale-wrap .presale-box {
    height: 80px;
    display: flex;
    align-items: center
}

.pay-box.fixed .presale-wrap .presale-box .step {
    padding-right: 38px;
    padding-bottom: 0;
    width: auto;
    min-width: 118px;
    height: 45px
}

.pay-box.fixed .presale-wrap .presale-box .step:nth-child(3) {
    height: auto
}

.pay-box.fixed .presale-wrap .presale-box .step .title {
    float: none;
    background: #fff
}

.pay-box.fixed .presale-wrap .presale-box .step .title .price {
    line-height: 26px;
    float: none
}

.pay-box.fixed .presale-wrap .presale-box .step .step-line {
    flex-direction: row;
    width: 100%;
    left: -14px
}

.pay-box.fixed .presale-wrap .presale-box .step .step-line .line {
    border-left: none;
    border-top: 1px dashed rgba(147,153,159,.3);
    width: 30px;
    height: 1px;
    position: absolute;
    right: 5px
}

.pay-box.fixed .presale-wrap .presale-box .step .step-line .circle:nth-child(3) {
    position: absolute;
    right: -10px
}

.btn {
  position: relative;
  display: inline-block;
  margin-bottom: 0;
  text-align: center;
  vertical-align: middle;
  touch-action: manipulation;
  text-decoration: none;
  box-sizing: border-box;
  background-image: none;
  -webkit-appearance: none;
  white-space: nowrap;
  outline: none;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  border-style: solid;
  border-width: 1px;
  cursor: pointer;
  transition: all .3s;
  color: #545c63;
  background-color: transparent;
  border-color: #9199a1;
  opacity: 1;
  padding: 7px 16px;
  font-size: 14px;
  line-height: 1.42857143;
  border-radius: 18px;
}

.btn-red {
  border-style: solid;
  border-width: 1px;
  cursor: pointer;
  -moz-transition: all .3s;
  transition: all .3s;
  color: #fff;
  background-color: #f20d0d;
  border-color: #f20d0d;
  opacity: 1;
}
.btn-red:hover {
  color: #fff;
  border-color: #c20a0a;
  background: #c20a0a;
  opacity: 1;
}
.pay-type {
  margin-top: 28px;
  margin-left: 12px;
}
.pay-type .title {
  margin-top: 28px;
}
.pay-type .list {
  padding-top: 20px;
}

.pay-type .list img {
  margin-right: 10px;
}
</style>

提交代码版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature: 客户端展示下单结算页面"
git push

展示购物车勾选商品列表

服务端实现购物车勾选商品列表的api接口

cart/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


# .... 中间代码省略

class CartOrderAPIView(APIView):
    """购物车确认下单接口"""
    # 保证用户必须是登录状态才能调用当前视图
    permission_classes = [IsAuthenticated]

    def get(self,request):
        """获取勾选商品列表"""
        # 查询购物车中的商品课程ID列表
        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": "购物车没有任何商品。"}, status=status.HTTP_204_NO_CONTENT)

        # 把redis中的购物车勾选课程ID信息转换成普通列表
        cart_list = [int(course_id.decode()) for course_id, selected in cart_hash.items() if selected == b'1']

        course_list = Course.objects.filter(pk__in=cart_list, is_deleted=False, is_show=True).all()

        # 把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(),
            })

        # 返回客户端
        return Response({"errmsg": "ok!", "cart": data})

cart/urls.py,路由,代码:

from django.urls import path
from . import views
urlpatterns = [
    path("", views.CartAPIView.as_view()),
    path("order/", views.CartOrderAPIView.as_view()),
]

客户端获取购物车勾选商品的数据

api/cart.js,代码:

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

const cart = reactive({
    // ... 中间代码省略
    select_course_list: [], // 购物车中被勾选的商品磕碜列表
    // ... 中间代码省略
    get_select_course(token){
        // 获取购物车中被勾选的商品列表
        return http.get("/cart/order/", {
            headers:{
                Authorization: "jwt " + token,
            }
        })
    }
})

export default cart;

api/order.js,代码:

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

const order = reactive({
  total_price: 0,      // 勾选商品的总价格
  use_coupon: false,   // 用户是否使用优惠
  discount_type: 0,    // 0表示优惠券,1表示积分
  coupon_list:[1,2,3], // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券下标,-1表示没有选择
  credit: 0,           // 当前用户选择抵扣的积分,0表示没有使用积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
})

export default order;

views/Order.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>
          <div class="right">
            <div class="">
              <span class="left"><router-link class="myorder-history" to="/cart">返回购物车</router-link></span>
            </div>
          </div>
        </div>
      </div>
      <div class="cart-body" id="cartBody">
        <div class="cart-body-title"><p class="item-1 l">课程信息</p></div>
        <div class="cart-body-table">
          <div class="item" v-for="course_info in cart.select_course_list">
              <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">
                      <p class="discount-price" v-if="course_info.discount.price>=0"><em>¥</em><span>{{course_info.discount.price.toFixed(2)}}</span></p>
                      <p :class="{'original-price': course_info.discount.price>=0}"><em>¥</em><span>{{course_info.price.toFixed(2)}}</span></p>
                  </div>
              </div>
          </div>
        </div>
        <div class="coupons-box">
          <div class="coupon-title-box">
            <p class="coupon-title">
              使用优惠券/积分
                <span v-if="order.use_coupon" @click="order.use_coupon=!order.use_coupon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" data-v-394d1fd8=""><path fill="currentColor" d="M831.872 340.864 512 652.672 192.128 340.864a30.592 30.592 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.592 30.592 0 0 0-42.752 0z"></path></svg></span>
                <span v-else @click="order.use_coupon=!order.use_coupon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" data-v-394d1fd8=""><path fill="currentColor" d="m488.832 344.32-339.84 356.672a32 32 0 0 0 0 44.16l.384.384a29.44 29.44 0 0 0 42.688 0l320-335.872 319.872 335.872a29.44 29.44 0 0 0 42.688 0l.384-.384a32 32 0 0 0 0-44.16L535.168 344.32a32 32 0 0 0-46.336 0z"></path></svg></span>
<!--                <i :class="order.use_coupon?'el-icon-arrow-up':'el-icon-arrow-down'" @click="order.use_coupon=!order.use_coupon"></i>-->
            </p>
          </div>
          <transition name="el-zoom-in-top">
          <div class="coupon-del-box" v-if="order.use_coupon">
            <div class="coupon-switch-box">
              <div class="switch-btn ticket" :class="{'checked': order.discount_type===0}" @click="order.discount_type=0">优惠券 (4)<em><i class="imv2-check"></i></em></div>
              <div class="switch-btn code" :class="{'checked': order.discount_type===1}" @click="order.discount_type=1">积分<em><i class="imv2-check"></i></em></div>
            </div>
            <div class="coupon-content ticket" v-if="order.discount_type===0">
              <p class="no-coupons" v-if="order.coupon_list.length<1">暂无可用优惠券</p>
              <div class="coupons-box" v-else>
               <div class="content-box">
                <ul class="nouse-box">
                 <li class="l">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥100 </p>
                    <p class="use-inst l">满499可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l select">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l wait-use">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l wait-use">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                </ul>
                <ul class="use-box">
                 <li class="l useing">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥100 </p>
                    <p class="use-inst l">满499可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                </ul>
                <ul class="overdue-box">
                 <li class="l useing">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥100 </p>
                    <p class="use-inst l">满499可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                </ul>
               </div>
              </div>
            </div>
            <div class="coupon-content code" v-else>
                <div class="input-box">
                  <el-input-number placeholder="10积分=1元" v-model="order.credit" :step="1" :min="0" :max="1000"></el-input-number>
                  <a class="convert-btn">兑换</a>
                </div>
                <div class="converted-box">
                  <p>使用积分:<span class="code-num">200</span></p>
                  <p class="course-title">课程:<span class="c_name">3天JavaScript入门</span>
                    <span class="discount-cash">100积分抵扣:<em>10</em>元</span>
                  </p>
                  <p class="course-title">课程:<span class="c_name">3天JavaScript入门</span>
                    <span class="discount-cash">100积分抵扣:<em>10</em>元</span>
                  </p>
                </div>
                <p class="error-msg">本次订单最多可以使用1000积分,您当前拥有200积分。(10积分=1元)</p>
                <p class="tip">说明:每笔订单只能使用一次积分,并只有在部分允许使用积分兑换的课程中才能使用。</p>
              </div>
          </div>
          </transition>
        </div>
        <div class="pay-type">
          <p class="title">选择支付方式</p>
          <div class="list">
            <img :src="order.pay_type==0?'/src/assets/alipay2.png':'/src/assets/alipay1.png'" @click="order.pay_type=0" alt="支付宝">
            <img :src="order.pay_type==1?'/src/assets/wechat2.png':'/src/assets/wechat1.png'" @click="order.pay_type=1" alt="微信">
            <img :src="order.pay_type==2?'/src/assets/yue2.png':'/src/assets/yue1.png'"  @click="order.pay_type=2" alt="余额">
          </div>
        </div>
        <div class="pay-box" :class="{fixed:order.fixed}">
                  <div class="row-bottom">
            <div class="row">
              <div class="goods-total-price-box">
                <p class="r rw price-num"><em>¥</em><span>1811.00</span></p>
                <p class="r price-text"><span>共<span>5</span>件商品,</span>商品总金额:</p>
              </div>
            </div>
            <div class="coupons-discount-box">
              <p class="r rw price-num">-<em>¥</em><span>60.00</span></p>
              <p class="r price-text">优惠券/积分抵扣:</p>
            </div>
            <div class="pay-price-box clearfix">
              <p class="r rw price"><em>¥</em><span id="js-pay-price">1751.00</span></p>
              <p class="r price-text">应付:</p>
            </div>
            <span class="r btn btn-red submit-btn">提交订单</span>
                    </div>
          <div class="pay-add-sign">
            <ul class="clearfix">
              <li>支持花呗</li>
              <li>可开发票</li>
              <li class="drawback">7天可退款</li>
            </ul>
          </div>
          </div>
      </div>
    </div>
    <Footer/>
  </div>
</template>
<script setup>
import {reactive,watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {useStore} from "vuex";
import cart from "../api/cart"
import order from "../api/order";

// let store = useStore()

const get_select_course = ()=>{
    // 获取购物车中的勾选商品列表
    let token = sessionStorage.token || localStorage.token;
    cart.get_select_course(token).then(response=>{
        cart.select_course_list = response.data.cart
    })
}

get_select_course();


// 监听用户选择的支付方式
watch(
    ()=>order.pay_type,
    ()=>{
      console.log(order.pay_type)
    }
)

// 底部订单总价信息固定浮动效果
window.onscroll = ()=>{
  let cart_body_table = document.querySelector(".cart-body-table")
  let offsetY = window.scrollY
  let maxY = cart_body_table.offsetTop+cart_body_table.offsetHeight
  order.fixed = offsetY < maxY
}
</script>

提交代码版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature: 确认下单页面中展示购物车勾选商品列表"
git push

订单生成

创建订单子应用

完成了勾选商品列表展示以后,因为优惠券或积分属于增值业务,所以可以先把优惠券功能和积分功能延后处理,先完成主流程中的订单生成功能。同时,为了方便以后项目的代码管理和维护,我们再次创建子应用orders来完成接下来的订单功能。

# 确认前面功能已经开发完整,review代码结束,向公司申请合并分支,开发合并分支
git checkout master
git merge feature/cart
# 查看线上本地所有的分支列表,可以看到本地的feature/user分支已经删除,但是线上的依然存在。
git branch --all
git branch -d feature/cart
# 本地删除了分支以后,线上分支也要同步一下。
git push origin --delete feature/cart
# 因为属于一个较大功能的开发合并,往往项目中都会打一个标签
git tag v0.0.4
# 提交标签版本
git push --tag
# git push origin v0.0.4

# 后续的功能属于购物流程里面的订单生成部分了
git checkout -b feature/order

# 创建订单子应用
python ../../manage.py startapp orders

注册子应用,settings/dev.py,代码:

INSTALLED_APPS = [
    # 子应用
    。。。

    'orders',
]

子路由,orders/urls.py,代码:

from django.urls import path
from . import views
urlpatterns = [

]

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

    path("orders/", include("orders.urls")),

订单模型

订单相关的模型分析:

订单基本信息:订单ID,支付方式,订单状态,支付时间,订单总价格,实付价格,订单标题,订单号,用户ID等等
订单项详情(订单与商品的关系):商品ID,商品原价、商品实价,优惠方式,订单ID等等

用户课程(用户与课程的关系):用户ID,课程ID,学习总时长等等
用户学习课程的进度跟踪记录(用户与课时的关系):用户ID,课时ID,课程ID,章节ID,学习进度(视频进度),学习时间等等

优惠券:优惠券标题、优惠券面额、优惠券优惠方式、优惠类型、领取方式(用户领取,系统发放)、起用时间、过期时间等等
用户的优惠券(用户与优惠券的关系):  用户ID,优惠券ID,领取时间等等。(我们采用redis来记录)
优惠券的使用记录(用户的优惠券与订单的关系):用户ID,优惠券ID、使用状态、订单ID等等。
积分流水:操作方式、积分面值、用户ID、订单ID等等。
余额流水:操作方式、货币面值、用户ID、订单ID等等。

为什么有订单号?

原因是支付平台需要记录每一个商家的资金流水,所以需要我们这边提供一个足够复杂的流水号和支付平台保持一致。
所以订单号是支付平台那边强制要求在支付时提供给平台的。用于对账。

orders/models.py,订单模型,代码:

from model import BaseModel,models
from users.models import User
from courses.models import Course
# Create your models here.


class Order(BaseModel):
    """订单基本信息模型"""
    status_choices = (
        # 模型对象.<字段名>                   获取元组的第一个成员
        # 模型对象.get_<字段名>_display()     获取元组的第二个成员
        (0, '未支付'),
        (1, '已支付'),
        (2, '已取消'),
        (3, '超时取消'),
    )
    pay_choices = (
        (0, '支付宝'),
        (1, '微信'),
        (2, '余额'),
    )

    total_price = models.DecimalField(default=0, max_digits=10, decimal_places=2, verbose_name="订单总价")
    real_price = models.DecimalField(default=0, max_digits=10, decimal_places=2, verbose_name="实付金额")
    order_number = models.CharField(max_length=64, verbose_name="订单号")
    order_status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="订单状态")
    pay_type = models.SmallIntegerField(choices=pay_choices, default=1, verbose_name="支付方式")
    order_desc = models.TextField(null=True, blank=True, max_length=500, verbose_name="订单描述")
    pay_time = models.DateTimeField(null=True, blank=True, verbose_name="支付时间")
    user = models.ForeignKey(User, related_name='user_orders', on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="下单用户")

    class Meta:
        db_table = "ly_order"
        verbose_name = "订单记录"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s,总价: %s,实付: %s" % (self.name, self.total_price, self.real_price)


class OrderDetail(BaseModel):
    """
    订单详情
    """
    order = models.ForeignKey(Order, related_name='order_courses', on_delete=models.CASCADE, db_constraint=False, verbose_name="订单")
    course = models.ForeignKey(Course, related_name='course_orders', on_delete=models.CASCADE, db_constraint=False, verbose_name="课程")
    price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="课程原价")
    real_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="课程实价")
    discount_name = models.CharField(max_length=120,default="",verbose_name="优惠类型")

    class Meta:
        db_table = "ly_order_course"
        verbose_name = "订单详情"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.course.name

数据迁移:

cd ../../
python manage.py makemigrations
python manage.py migrate

提交版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature:订单子应用创建以及订单信息和订单项模型的创建"
git push --set-upstream origin feature/order

把订单子应用相关的模型注册到admin管理站点

orders/admin.py,代码:

from django.contrib import admin
from .models import Order, OrderDetail


# class OrderDetailInLine(admin.StackedInline):
class OrderDetailInLine(admin.TabularInline):
    """订单项的内嵌类"""
    model = OrderDetail
    fields = ["course", "price", "real_price", "discount_name"]
    # readonly_fields = ["discount_name"]


class OrderModelAdmin(admin.ModelAdmin):
    """订单信息的模型管理器"""
    list_display = ["id","order_number","user","total_price","total_price","order_status"]
    inlines = [OrderDetailInLine, ]


admin.site.register(Order, OrderModelAdmin)

orders/apps.py,代码:

from django.apps import AppConfig

class OrdersConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'orders'
    verbose_name = "订单管理"
    verbose_name_plural = verbose_name

提交版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature:把订单子应用相关的模型注册到admin管理站点"
git push origin feature/order

服务端提供创建订单的api接口

orders/views.py,代码:

from rest_framework.generics import CreateAPIView
from .models import Order
from .serializers import OrderModelSerializer
from rest_framework.permissions import IsAuthenticated


# Create your views here.
class OrderCreateAPIView(CreateAPIView):
    """创建订单"""
    permission_classes = [IsAuthenticated]
    queryset = Order.objects.all()
    serializer_class = OrderModelSerializer

子路由,orders/urls.py,代码:

from django.urls import path
from . import views

urlpatterns = [
    path("", views.OrderCreateAPIView.as_view()),
]

序列化器,orders/serializers.py,代码:

from datetime import datetime
from rest_framework import serializers
from django_redis import get_redis_connection
from .models import Order, OrderDetail, Course


class OrderModelSerializer(serializers.ModelSerializer):
    pay_link = serializers.CharField(read_only=True)

    class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number", "pay_link"]
        read_only_fields = ["id", "order_number"]
        extra_kwargs = {
            "pay_type": {"write_only": True},
        }

    def create(self, validated_data):
        """创建订单"""
        redis = get_redis_connection("cart")
        user_id = self.context["request"].user.id  # 1

        # 创建订单记录
        order = Order.objects.create(
            name="购买课程",  # 订单标题
            user_id=user_id,  # 当前下单的用户ID
            # order_number = datetime.now().strftime("%Y%m%d%H%M%S") + ("%08d" % user_id) + "%08d" % random.randint(1,99999999) # 基于随机数生成唯一订单号
            order_number=datetime.now().strftime("%Y%m%d") + ("%08d" % user_id) + "%08d" % redis.incr("order_number"), # 基于redis生成分布式唯一订单号
            pay_type=validated_data.get("pay_type"),  # 支付方式
        )

        # 记录本次下单的商品列表
        cart_hash = redis.hgetall(f"cart_{user_id}")
        if len(cart_hash) < 1:
            raise serializers.ValidationError(detail="购物车没有要下单的商品")

        # 提取购物车中所有勾选状态为b'1'的商品
        course_id_list = [int(key.decode()) for key, value in cart_hash.items() if value == b'1']

        # 添加订单与课程的关系
        course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()
        detail_list = []
        total_price = 0 # 本次订单的总价格
        real_price = 0  # 本次订单的实付总价

        for course in course_list:
            discount_price = float(course.discount.get("price", 0)) # 获取课程原价
            discount_name = course.discount.get("type", "")
            detail_list.append(OrderDetail(
                order=order,
                course=course,
                name=course.name,
                price=course.price,
                real_price=discount_price,
                discount_name=discount_name,
            ))

            # 统计订单的总价和实付总价
            total_price += float(course.price)
            real_price += discount_price if discount_price > 0 else float(course.price)

        # 一次性批量添加本次下单的商品记录
        OrderDetail.objects.bulk_create(detail_list)

        # 保存订单的总价格和实付价格
        order.total_price = total_price
        order.real_price = real_price
        order.save()

        # todo 支付链接地址[后面实现支付功能的时候,再做]
        order.pay_link = ""
        return order

生成订单时,在序列化器中要接收客户端用户的user_id

用户ID在序列化器中接收到视图中的数据,那么在序列化器初始化的时候,其实有3个参数可以填写:
   1. instance 模型对象,数据模型,
   2. data     字典,客户端提交数据,
   3. context  字典,额外参数[执行上下文],如果要自定义参数,可以直接通过字典格式声明,然后到context

   OrderModerSerializer(instance="模型对象",data="客户端数据", context={})

利用序列化器初始化时提供的第三个参数就可以调用到视图类的
   context的属性          描述                       序列化器中的调用代码
       request    本次客户端的请求对象            self.context["request"]
       format     本次服务器响应的数据格式         self.context["format"]
       view       调用当前序列化器的视图类          self.context["view"]

因此,我们要在序列化器中提取用户的id,代码如下:
   user_id = self.context["request"].user.id

提交版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature:服务端提供创建订单的API接口"
git push

上面我们完成了订单信息的添加,但是下单不是一个数据记录而已,而是多张表记录的同时添加操作。所以针对这种多个记录或者多张表连贯进行的操作,为了保证数据的完整性和一致性以及原子性,我们要使用数据库的事务(Transaction)来完成,当然我们这个项目中不需要使用到数据库原生的事务语句,而是使用django的ORM提供的事务模块即可。

事务(Transaction),是以功能或业务作为逻辑单位,把一条或多条SQL语句组成一个不可分割的操作序列来执行的数据库特性。
在完成一个整体功能时,操作到了多个表数据,或者同一个表的多条记录,如果要保证这些SQL语句操作作为一个整体保存到数据库中,那么可以使用事务(transaction),保证这些操作作为不可分割的整体,要么一起成功,要么一起失败。

事务具有4个特性(ACID),5个隔离等级

  四个特性:一致性,原子性,隔离性,持久性
  # 隔离性:两个事务的隔离性,隔离性的修改可以通过数据库的配置文件mysqld.cnf进行修改,默认mysql是属于可重复级别
  五个隔离级别(从高到低): 串行隔离,可重复读,已提交读,未提交读,没有隔离
    原子性(Atomicity)
    一致性(Consistency)
    隔离性(Isolation)[事务隔离级别->幻读,脏读, 不可重复读]
    持久性(Durability)

  在mysql中有专门的SQl语句来完成事务的操作,事务的代码操作一般有3个步骤:
     设置事务开始  begin;
     事务的处理[mysql:增删改]
         redis.sadd()
         事务的处理[mysql:增删改]
     设置事务的回滚或者提交 rollback / commit;  # 这个事务过程中,事务无法对mysql数据库以外的其他类型的数据库操作进行管理和回滚
mysql中底层的事务是如何实现事务的回滚操作:undo.log重做日志
在ORM框架一般都会实现了事务操作封装,所以我们可以直接使用ORM框架即可完成事务的操作

django框架本身就提供了2种事务操作的写法,主要都是通过 django.db.transaction模块完成的。

启用事务写法1:基于装饰器对函数或方法进行事务管理:

from django.db import transaction
from rest_framework.views import APIView
class OrderAPIView(APIView):
    @transaction.atomic          # 开启事务,当函数/方法执行完成以后,自动提交事务
    def post(self,request):      # 不一定是视图方法,也可以是其他函数方法。
        ....  # 在整个函数或者方法中,进行的所有SQL数据写操作[增删改],都属于同一个事务操作

启用事务写法2,基于with上下文管理器进行事务管理:

from django.db import transaction
from rest_framework.views import APIView
class OrderAPIView(APIView):
    def post(self,request):
        .... # 事务以外的,其他的SQL数据操作
        with transation.atomic(): # 开启事务,当with语句执行完成以后,自动提交事务
            # 数据库操作【DML增删改】

        .... # with语句以外的其他的SQL数据操作,无法被上面事务管理

在使用事务过程中, 有时候会出现异常,当出现异常时我们需要回滚事务。

from django.db import transaction
from rest_framework.generics import CreateAPIView
class OrderCreateAPIView(CreateAPIView):
    def post(self,request):
        ....
        with transaction.atomic():
            # 1、设置事务回滚的标记点【一个事物中可以设置多个回滚标记】
            sid1 = transaction.savepoint()
            try:
                .... # 增删改等数据库操作
                ....
            except:
                transaction.savepoint_rallback(sid1)

        .... # 数据库操作,注意,如果这里被执行,因为没有在with里面,所以是不会被上面的事务操作影响。

django的事务操作是支持嵌套事务的,但是mysql本身不支持嵌套事务。

from django.db import transaction
from rest_framework.generics import CreateAPIView
class OrderCreateAPIView(CreateAPIView):
    def post(self,request):
        ....
        with transaction.atomic():
            # 1、设置事务回滚的标记点【一个事物中可以设置多个回滚标记】
            sid1 = transaction.savepoint()

            try:
                .... # 增删改等数据库操作
                ....
                with transaction.atomic():
                    # 2. 设置回滚点
                    sid2 = transaction.savepoint()
                    try:
                        .... # 其他内部数据库处理
                        ....
                    except:
                        transaction.savepoint_rallback(sid2)
            except:
                transaction.savepoint_rallback(sid1)

        .... # 数据库操作,注意,如果这里被执行,因为没有在with里面,所以是不会被上面的事务操作影响。

使用Django的ORM提供的mysql事务操作保证下单过程中的数据原子性

orders/serializers.py,代码:

from datetime import datetime
from rest_framework import serializers
from django_redis import get_redis_connection
from django.db import transaction
from .models import Order, OrderDetail, Course
import logging

logger = logging.getLogger("django")


class OrderModelSerializer(serializers.ModelSerializer):
    pay_link = serializers.CharField(read_only=True)

    class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number", "pay_link"]
        read_only_fields = ["id", "order_number"]
        extra_kwargs = {
            "pay_type": {"write_only": True},
        }

    def create(self, validated_data):
        """创建订单"""
        redis = get_redis_connection("cart")
        user_id = self.context["request"].user.id  # 1

        # 开启事务操作,保证下单过程中的所有数据库的原子性
        with transaction.atomic():
            # 设置事务的回滚点标记
            t1 = transaction.savepoint()
            try:
                # 创建订单记录
                order = Order.objects.create(
                    name="购买课程",  # 订单标题
                    user_id=user_id,  # 当前下单的用户ID
                    # order_number = datetime.now().strftime("%Y%m%d%H%M%S") + ("%08d" % user_id) + "%08d" % random.randint(1,99999999) # 基于随机数生成唯一订单号
                    order_number=datetime.now().strftime("%Y%m%d") + ("%08d" % user_id) + "%08d" % redis.incr("order_number"), # 基于redis生成分布式唯一订单号
                    pay_type=validated_data.get("pay_type"),  # 支付方式
                )

                # 记录本次下单的商品列表
                cart_hash = redis.hgetall(f"cart_{user_id}")
                if len(cart_hash) < 1:
                    raise serializers.ValidationError(detail="购物车没有要下单的商品")

                # 提取购物车中所有勾选状态为b'1'的商品
                course_id_list = [int(key.decode()) for key, value in cart_hash.items() if value == b'1']

                # 添加订单与课程的关系
                course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()
                detail_list = []
                total_price = 0 # 本次订单的总价格
                real_price = 0  # 本次订单的实付总价

                for course in course_list:
                    discount_price = float(course.discount.get("price", 0)) # 获取课程原价
                    discount_name = course.discount.get("type", "")
                    detail_list.append(OrderDetail(
                        order=order,
                        course=course,
                        name=course.name,
                        price=course.price,
                        real_price=discount_price,
                        discount_name=discount_name,
                    ))

                    # 统计订单的总价和实付总价
                    total_price += float(course.price)
                    real_price += discount_price if discount_price > 0 else float(course.price)

                # 一次性批量添加本次下单的商品记录
                OrderDetail.objects.bulk_create(detail_list)

                # 保存订单的总价格和实付价格
                order.total_price = total_price
                order.real_price = real_price
                order.save()

                # todo 支付链接地址[后面实现支付功能的时候,再做]
                order.pay_link = ""
                return order
            except Exception as e:
                # 1. 记录日志
                logger.error(f"订单创建失败:{e}")
                # 2. 事务回滚
                transaction.savepoint_rollback(t1)
                # 3. 抛出异常,通知视图返回错误提示
                raise serializers.ValidationError(detail="订单创建失败!")

购物车中选中的商品被记录到了订单中,那么购物车中原来的勾选商品是否要删除?

如果不删除,那么订单中的商品与购物车中就重复了,所以要删除,购物车中只需要保留没有勾选过的商品。

orders/serializers.py,代码:

from datetime import datetime
from rest_framework import serializers
from django_redis import get_redis_connection
from django.db import transaction
from .models import Order, OrderDetail, Course
import logging

logger = logging.getLogger("django")


class OrderModelSerializer(serializers.ModelSerializer):
    pay_link = serializers.CharField(read_only=True)

    class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number", "pay_link"]
        read_only_fields = ["id", "order_number"]
        extra_kwargs = {
            "pay_type": {"write_only": True},
        }

    def create(self, validated_data):
        """创建订单"""
        redis = get_redis_connection("cart")
        user_id = self.context["request"].user.id  # 1

        # 开启事务操作,保证下单过程中的所有数据库的原子性
        with transaction.atomic():
            # 设置事务的回滚点标记
            t1 = transaction.savepoint()
            try:
                # 创建订单记录
                order = Order.objects.create(
                    name="购买课程",  # 订单标题
                    user_id=user_id,  # 当前下单的用户ID
                    # order_number = datetime.now().strftime("%Y%m%d%H%M%S") + ("%08d" % user_id) + "%08d" % random.randint(1,99999999) # 基于随机数生成唯一订单号
                    order_number=datetime.now().strftime("%Y%m%d") + ("%08d" % user_id) + "%08d" % redis.incr("order_number"), # 基于redis生成分布式唯一订单号
                    pay_type=validated_data.get("pay_type"),  # 支付方式
                )

                # 记录本次下单的商品列表
                cart_hash = redis.hgetall(f"cart_{user_id}")
                if len(cart_hash) < 1:
                    raise serializers.ValidationError(detail="购物车没有要下单的商品")

                # 提取购物车中所有勾选状态为b'1'的商品
                course_id_list = [int(key.decode()) for key, value in cart_hash.items() if value == b'1']

                # 添加订单与课程的关系
                course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()
                detail_list = []
                total_price = 0 # 本次订单的总价格
                real_price = 0  # 本次订单的实付总价

                for course in course_list:
                    discount_price = float(course.discount.get("price", 0)) # 获取课程原价
                    discount_name = course.discount.get("type", "")
                    detail_list.append(OrderDetail(
                        order=order,
                        course=course,
                        name=course.name,
                        price=course.price,
                        real_price=discount_price,
                        discount_name=discount_name,
                    ))

                    # 统计订单的总价和实付总价
                    total_price += float(course.price)
                    real_price += discount_price if discount_price > 0 else float(course.price)

                # 一次性批量添加本次下单的商品记录
                OrderDetail.objects.bulk_create(detail_list)

                # 保存订单的总价格和实付价格
                order.total_price = total_price
                order.real_price = real_price
                order.save()

                # todo 支付链接地址[后面实现支付功能的时候,再做]
                order.pay_link = ""

                # 删除购物车中被勾选的商品,保留没有被勾选的商品信息
                cart = {key: value for key, value in cart_hash.items() if value == b'0'}
                pipe = redis.pipeline()
                pipe.multi()
                # 删除原来的购物车
                pipe.delete(f"cart_{user_id}")
                # 重新把未勾选的商品记录到购物车中
                pipe.hmset(f"cart_{user_id}", cart)  # hset 在新版本的redis中实际上hmset已经被废弃了,改用hset替代hmset
                pipe.execute()

                return order
            except Exception as e:
                # 1. 记录日志
                logger.error(f"订单创建失败:{e}")
                # 2. 事务回滚
                transaction.savepoint_rollback(t1)
                # 3. 抛出异常,通知视图返回错误提示
                raise serializers.ValidationError(detail="订单创建失败!")

提交版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature:服务端基于事务保证订单生成操作的原子性"
git push

客户端请求生成订单

api/order.js,代码:

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

const order = reactive({
  total_price: 0,      // 勾选商品的总价格
  use_coupon: false,   // 用户是否使用优惠
  discount_type: 0,    // 0表示优惠券,1表示积分
  coupon_list:[1,2,3], // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券下标,-1表示没有选择
  credit: 0,           // 当前用户选择抵扣的积分,0表示没有使用积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
  create_order(token){
    // 生成订单
    return http.post("/orders/",{
        pay_type: this.pay_type
    },{
        headers:{
            Authorization: "jwt " + token,
        }
    })
  }
})

export default order;

views/Order.vue,代码:

<span class="r btn btn-red submit-btn" @click="commit_order">提交订单</span>
<script setup>
import {reactive,watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {useStore} from "vuex";
import cart from "../api/cart"
import order from "../api/order";
import {ElMessage} from "element-plus";
import router from "../router";

// let store = useStore()

const get_select_course = ()=>{
    // 获取购物车中的勾选商品列表
    let token = sessionStorage.token || localStorage.token;
    cart.get_select_course(token).then(response=>{
        cart.select_course_list = response.data.cart
        if(response.data.cart.length === 0){
          ElMessage.error("当前购物车中没有下单的商品!请重新重新选择购物车中要购买的商品~");
          router.back();
        }
    }).catch(error=>{
    if(error?.response?.status===400){
      ElMessage.error("登录超时!请重新登录后再继续操作~");
    }
  })
}

get_select_course();


const commit_order = ()=>{
    // 生成订单
    let token = sessionStorage.token || localStorage.token;
    order.create_order(token).then(response=>{
    console.log(response.data.order_number)  // todo 订单号
    console.log(response.data.pay_link)      // todo 支付链接
    // 成功提示
    ElMessage.success("下单成功!马上跳转到支付页面,请稍候~")
    // 扣除掉被下单的商品数量,更新购物车中的商品数量
    store.commit("set_cart_total", store.state.cart_total - cart.select_course_list.length);
  }).catch(error=>{
    if(error?.response?.status===400){
          ElMessage.success("登录超时!请重新登录后再继续操作~");
    }
  })
}


// 监听用户选择的支付方式
watch(
    ()=>order.pay_type,
    ()=>{
      console.log(order.pay_type)
    }
)

// 底部订单总价信息固定浮动效果
window.onscroll = ()=>{
  let cart_body_table = document.querySelector(".cart-body-table")
  let offsetY = window.scrollY
  let maxY = cart_body_table.offsetTop+cart_body_table.offsetHeight
  order.fixed = offsetY < maxY
}
</script>

提交版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端请求生成订单"
git push