订单管理

chapter7.4-订单处理 - 图1

上面的效果,可以使用vue-router的嵌套路由来实现,根据不同的子路径,来指定部分内容改变,不会切换公共部分内容。

个人中心

订单列表位于用户中心页面,所以我们接下来先完善头部子组件中的跳转链接。

src/components/Header.vue,代码:

  1. <el-dropdown>
  2. <span class="el-dropdown-link">
  3. <router-link to="/user">
  4. <el-avatar class="avatar" size="50" src="https://fuguangapi.oss-cn-beijing.aliyuncs.com/avatar.jpg"></el-avatar>
  5. </router-link>
  6. </span>
  7. <template #dropdown>
  8. <el-dropdown-menu>
  9. <el-dropdown-item :icon="UserFilled"><router-link to="/user">个人中心</router-link></el-dropdown-item>
  10. <el-dropdown-item :icon="List">订单列表</el-dropdown-item>
  11. <el-dropdown-item :icon="Setting">个人设置</el-dropdown-item>
  12. <el-dropdown-item :icon="Position" @click="logout">注销登录</el-dropdown-item>
  13. </el-dropdown-menu>
  14. </template>
  15. </el-dropdown>

个人中心页面效果展示,个人信息页面:

chapter7.4-订单处理 - 图2

我的订单页面:

chapter7.4-订单处理 - 图3

个人中心主页面,src/views/User.vue,代码:

  1. <template>
  2. <Header></Header>
  3. <main class="clearfix">
  4. <div class="bg-other user-head-info">
  5. <div class="user-info clearfix">
  6. <div class="user-pic" data-is-fans="0" data-is-follows="">
  7. <div class="user-pic-bg"><img class="img" :src="store.state.user.avatar" alt=""></div>
  8. </div>
  9. <div class="user-info-right">
  10. <h3 class="user-name clearfix"><span>墨落</span></h3>
  11. <p class="about-info">
  12. <span></span>
  13. <span>CG影视动画师</span>
  14. </p>
  15. </div>
  16. <div class="user-sign hide">
  17. <p class="user-desc">这位同学很懒,木有签名的说~</p>
  18. </div>
  19. <div class="study-info clearfix">
  20. <div class="item follows">
  21. <div class="u-info-learn" title="学习时长16分" style="cursor:pointer;">
  22. <em>0.28h</em>
  23. <span>学习时长 </span>
  24. </div>
  25. </div>
  26. <div class="item follows">
  27. <router-link to="/u/index/credit"><em>0</em></router-link>
  28. <span>积分</span>
  29. </div>
  30. <div class="item follows">
  31. <router-link to="/u/index/follows"><em>12</em></router-link>
  32. <span>关注</span>
  33. </div>
  34. <div class="item follows">
  35. <router-link to="/user/setbindsns" class="set-btn"><i class="icon-set"></i>个人设置</router-link>
  36. </div>
  37. </div>
  38. </div>
  39. </div>
  40. <div class="main clearfix">
  41. <div class="slider l">
  42. <h1>个人中心</h1>
  43. <ul class="nav-menu">
  44. <li class="clearfix" :class="{active:route.path === '/user'}">
  45. <router-link to="/user">
  46. <p class="nav-name l">个人信息</p>
  47. <span class="el-icon-caret-right r"></span>
  48. </router-link>
  49. </li>
  50. <li class="clearfix" :class="{active:route.path === '/user/course'}">
  51. <router-link to="/user/course">
  52. <p class="nav-name l">我的课程</p>
  53. <span class="el-icon-caret-right r"></span>
  54. </router-link>
  55. </li>
  56. <li class="clearfix" :class="{active:route.path === '/user/order'}">
  57. <router-link to="/user/order">
  58. <p class="nav-name l">我的订单</p>
  59. <span class="el-icon-caret-right r"></span>
  60. </router-link>
  61. </li>
  62. <li class="clearfix" :class="{active:route.path === '/user/balance'}">
  63. <router-link to="/user/balance">
  64. <p class="nav-name l">我的余额</p>
  65. <span class="el-icon-caret-right r"></span>
  66. </router-link>
  67. </li>
  68. <li class="clearfix" :class="{active:route.path === '/user/coupon'}">
  69. <router-link to="/user/coupon">
  70. <p class="nav-name l">我的优惠券</p>
  71. <span class="el-icon-caret-right r"></span>
  72. </router-link>
  73. </li>
  74. <li class="clearfix" :class="{active:route.path === '/user/bill'}">
  75. <router-link to="/user/bill">
  76. <p class="nav-name l">我的消费记录</p>
  77. <span class="el-icon-caret-right r"></span>
  78. </router-link>
  79. </li>
  80. </ul>
  81. </div>
  82. <!-- 嵌套路由,也是依靠router-view来加载不同的子页面内容 -->
  83. <router-view></router-view>
  84. </div>
  85. </main>
  86. <Footer></Footer>
  87. </template>
  88. <script setup>
  89. import Header from "../components/Header.vue"
  90. import Footer from "../components/Footer.vue"
  91. import settings from "../settings";
  92. import {useStore} from "vuex"
  93. import {useRoute} from "vue-router"
  94. const store = useStore()
  95. const route = useRoute()
  96. </script>
  97. <style scoped>
  98. main{
  99. margin-bottom: 40px;
  100. }
  101. .bg-other {
  102. background: url("../assets/user_bg.png") no-repeat center top #000;
  103. background-size: cover;
  104. }
  105. .user-head-info{
  106. min-height: 200px;
  107. }
  108. .user-head-info .user-info {
  109. position: relative;
  110. width: 1500px;
  111. margin: 0 auto;
  112. min-height: 200px;
  113. }
  114. .user-head-info .user-info .user-pic {
  115. float: left;
  116. width: 148px;
  117. height: 148px
  118. }
  119. .user-head-info .user-info .user-pic .user-pic-bg {
  120. border: 4px solid #fff;
  121. box-shadow: 0 4px 8px 0 rgba(7,17,27,.1);
  122. width: 140px;
  123. height: 140px;
  124. position: relative;
  125. border-radius: 50%;
  126. background: #fff;
  127. top: 24px
  128. }
  129. .user-head-info .user-info .user-pic .user-pic-bg .img {
  130. text-align: center;
  131. width: 140px;
  132. height: 140px;
  133. border-radius: 50%
  134. }
  135. .user-head-info .user-info .user-info-right {
  136. float: right;
  137. width: 1330px;
  138. }
  139. .user-head-info .user-info .user-name {
  140. font-weight: 600;
  141. text-align: left;
  142. font-size: 24px;
  143. color: #fff;
  144. line-height: 28px;
  145. margin-top: 48px;
  146. margin-bottom: 10px;
  147. }
  148. .user-head-info .user-info .about-info {
  149. font-size: 14px;
  150. color: #fff;
  151. line-height: 20px;
  152. text-align: left;
  153. margin-top: 6px;
  154. display: block;
  155. }
  156. .user-head-info .user-info .about-info span {
  157. display: inline-block;
  158. margin-right: 10px;
  159. font-size: 14px;
  160. color: #fff;
  161. line-height: 20px
  162. }
  163. .user-head-info .user-info .user-sign {
  164. font-size: 14px;
  165. color: #fff;
  166. line-height: 24px;
  167. width: 440px;
  168. overflow: hidden;
  169. word-break: break-all;
  170. word-wrap: break-word
  171. }
  172. .user-head-info .user-info .user-desc {
  173. font-size: 14px;
  174. line-height: 24px;
  175. color: #fff;
  176. text-align: left;
  177. margin-top: 20px;
  178. word-break: break-all;
  179. word-wrap: break-word;
  180. opacity: .8;
  181. margin-left: 24px
  182. }
  183. .user-head-info .study-info {
  184. position: absolute;
  185. top: 48px;
  186. right: 10px;
  187. min-width: 200px;
  188. text-align: right
  189. }
  190. .user-head-info .study-info .item {
  191. line-height: 48px;
  192. vertical-align: middle;
  193. height: 48px;
  194. float: left
  195. }
  196. .user-head-info .study-info .item em {
  197. display: block;
  198. text-align: center;
  199. font-weight: 700;
  200. font-size: 24px;
  201. color: rgba(255,255,255,.8);
  202. line-height: 28px
  203. }
  204. .user-head-info .study-info .item span {
  205. display: block;
  206. text-align: center;
  207. font-size: 14px;
  208. color: rgba(255,255,255,.8);
  209. line-height: 20px;
  210. margin-top: 4px
  211. }
  212. .user-head-info .study-info .follows {
  213. margin-right: 24px
  214. }
  215. .user-head-info .study-info .set-btn {
  216. padding: 8px 16px;
  217. border: 1px solid rgba(255,255,255,.4);
  218. border-radius: 18px;
  219. font-size: 14px;
  220. color: rgba(255,255,255,.8);
  221. line-height: 20px;
  222. height: 20px
  223. }
  224. .user-head-info .study-info .set-btn i {
  225. font-size: 16px;
  226. display: inline-block;
  227. margin-right: 4px
  228. }
  229. .user-head-info .study-info .set-btn:hover {
  230. color: #fff;
  231. border-color: #fff
  232. }
  233. .l {
  234. float: left;
  235. }
  236. .r {
  237. float: right;
  238. }
  239. .clearfix:after {
  240. content: '\0020';
  241. display: block;
  242. height: 0;
  243. clear: both;
  244. visibility: hidden;
  245. }
  246. .main{
  247. width: 1500px;
  248. margin: 36px auto;
  249. }
  250. .slider {
  251. margin-right: 32px;
  252. width: 180px;
  253. box-sizing: border-box
  254. }
  255. .slider h1 {
  256. padding-bottom: 16px;
  257. font-size: 14px;
  258. color: #4d555d;
  259. line-height: 32px;
  260. border-bottom: 1px solid #d9dde1
  261. }
  262. .slider .nav-menu {
  263. width: 100%
  264. }
  265. .slider .nav-menu li {
  266. margin-top: 16px;
  267. width: 100%;
  268. height: 32px;
  269. line-height: 32px;
  270. box-sizing: border-box;
  271. cursor: pointer;
  272. font-size: 14px;
  273. color: #4d555d
  274. }
  275. .slider .nav-menu li a {
  276. color: #07111b
  277. }
  278. .slider .nav-menu li a:hover {
  279. color: #f01414
  280. }
  281. .slider .nav-menu li .nav-name {
  282. font-size: 14px
  283. }
  284. .slider .nav-menu li .el-icon-caret-right {
  285. font-size: 16px;
  286. line-height: 32px
  287. }
  288. .slider .nav-menu li:hover {
  289. color: #07111b
  290. }
  291. .slider .nav-menu li:hover a {
  292. color: #07111b
  293. }
  294. .slider .nav-menu li:hover .el-icon-caret-right {
  295. color: #07111b
  296. }
  297. .slider .nav-menu li.active {
  298. color: #f01414
  299. }
  300. .slider .nav-menu li.active a {
  301. color: #f01414
  302. }
  303. .slider .nav-menu li.active a:hover {
  304. color: #f01414
  305. }
  306. .slider .nav-menu li.active .el-icon-caret-right {
  307. color: #f01414
  308. }
  309. </style>

个人信息页面效果展示

src/components/user/Info.vue,代码:

  1. <template>
  2. <div class="setting-right">
  3. <div class="setting-right-wrap wrap-boxes settings">
  4. <div class="formBox">
  5. <div id="setting-profile" class="setting-wrap setting-profile">
  6. <div class="common-title">
  7. 个人信息
  8. <a href="javascript: void(0);" class="pull-right js-edit-info"><i class="el-icon-edit"></i>编辑</a>
  9. </div>
  10. <div class="line"></div>
  11. <div class="info-wapper">
  12. <div class="info-box clearfix">
  13. <label class="pull-left">昵称</label>
  14. <div class="pull-left">墨落</div>
  15. </div>
  16. <div class="info-box clearfix">
  17. <label class="pull-left">职位</label>
  18. <div class="pull-left">CG影视动画师</div>
  19. </div>
  20. <div class="info-box clearfix">
  21. <label class="pull-left">城市</label>
  22. <div class="pull-left">未设置</div>
  23. </div>
  24. <div class="info-box clearfix">
  25. <label class="pull-left">性别</label>
  26. <div class="pull-left"></div>
  27. </div>
  28. <div class="info-box clearfix">
  29. <label class="pull-left">个性签名</label>
  30. <div class="pull-left">未设置</div>
  31. </div>
  32. </div>
  33. </div>
  34. </div>
  35. </div>
  36. </div>
  37. </template>
  38. <script setup>
  39. </script>
  40. <style scoped>
  41. .clearfix:after {
  42. content: '\0020';
  43. display: block;
  44. height: 0;
  45. clear: both;
  46. visibility: hidden;
  47. }
  48. .setting-right {
  49. float: left;
  50. width: 1284px;
  51. box-sizing: border-box;
  52. background-color: #fff
  53. }
  54. .setting-right-wrap {
  55. min-height: 550px
  56. }
  57. .pull-left {
  58. float: left;
  59. }
  60. .pull-right {
  61. float: right;
  62. }
  63. .common-title {
  64. line-height: 32px;
  65. font-size: 16px;
  66. font-weight: 700;
  67. }
  68. .common-title a {
  69. color: #93999f;
  70. font-weight: 400;
  71. }
  72. .common-title a:hover {
  73. color: #008cc8;
  74. }
  75. .common-title a i {
  76. color: #008cc8;
  77. margin-right: 4px;
  78. vertical-align: middle;
  79. }
  80. .line {
  81. height: 1px;
  82. background-color: #d0d6d9;
  83. margin-top: 12px;
  84. }
  85. .setting-profile {
  86. padding: 0!important
  87. }
  88. .setting-profile .info-wapper {
  89. margin: 24px auto 24px 40px
  90. }
  91. .setting-profile .info-box {
  92. margin-bottom: 12px
  93. }
  94. .setting-profile .info-box label {
  95. width: 180px;
  96. line-height: 20px;
  97. padding: 20px 0;
  98. text-align: center;
  99. background-color: #f3f5f7;
  100. color: #07111b;
  101. font-weight: 700
  102. }
  103. .setting-profile .info-box div {
  104. width: 1034px;
  105. margin-left: 8px;
  106. line-height: 20px;
  107. padding: 20px 0 20px 22px;
  108. border-bottom: 1px solid #d9dde1
  109. }
  110. .edit-info .wlfg-wrap textarea {
  111. height: 70px
  112. }
  113. .edit-info .wlfg-wrap input {
  114. font-size: 14px
  115. }
  116. </style>

我的订单页面展示

src/components/user/Order.vue,代码:

  1. <template>
  2. <div class="right-container l">
  3. <div class="right-title">
  4. <h2>我的订单</h2>
  5. <ul>
  6. <li class="action"><router-link to="/user/order">全部<i class="js-all-num">3</i></router-link></li>
  7. <li><router-link to="/user/order?type=unpaid">未支付</router-link></li>
  8. <li><router-link to="/user/order?type=paid">已完成</router-link></li>
  9. <li><router-link to="/user/order?type=invalid">已废弃</router-link></li>
  10. </ul>
  11. </div>
  12. <div class="myOrder">
  13. <ul class="myOrder-list">
  14. <li data-flag="2107312249236254">
  15. <p class="myOrder-number">
  16. <i class="imv2-receipt"></i>订单编号:2107312249236254
  17. <span class="date">2021-07-31 22:49:23</span>
  18. <i class="imv2-delete js-order-del" title="删除订单"></i>
  19. <router-link to="/user/help" target="_blank" class="myfeedback r">售后帮助</router-link>
  20. </p>
  21. <div class="myOrder-course clearfix">
  22. <dl class="course-del l">
  23. <dd class="clearfix">
  24. <router-link to="" class="l"><img class="l" src="" width="160" height="90"></router-link>
  25. <div class="del-box l">
  26. <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
  27. <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
  28. <router-link to="/course/525"><p class="course-name">晋级TypeScript高手,成为抢手的前端开发人才</p></router-link>
  29. <p class="price-btn-box clearfix">
  30. <!-- 如果有优惠券 -->
  31. <span class="l truepay-text">实付</span>
  32. <span class="l course-little-price">¥358.00</span>
  33. </p>
  34. </div>
  35. </dd>
  36. <dd class="clearfix">
  37. <router-link to="" class="l"><img class="l" src="" width="160" height="90"></router-link>
  38. <div class="del-box l">
  39. <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
  40. <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
  41. <router-link to="/course/525"><p class="course-name">晋级TypeScript高手,成为抢手的前端开发人才</p></router-link>
  42. <p class="price-btn-box clearfix">
  43. <!-- 如果有优惠券 -->
  44. <span class="l truepay-text">实付</span>
  45. <span class="l course-little-price">¥358.00</span>
  46. </p>
  47. </div>
  48. </dd>
  49. </dl>
  50. <!-- 使用优惠券 -->
  51. <div class="course-money l pt15">
  52. <div class="wrap">
  53. <div class="type-box clearfix mb10">
  54. <p class="type-text l">原价</p>
  55. <p class="type-price l line-though"><span class="RMB">¥</span>399.00</p>
  56. </div>
  57. <div class="type-box clearfix mb10">
  58. <p class="type-text l">折扣</p>
  59. <p class="type-price l">-<span class="RMB">¥</span>41.00</p>
  60. </div>
  61. <div class="total-box clearfix">
  62. <p class="type-text l">实付</p>
  63. <p class="type-price l"><span class="RMB">¥</span>358.00</p>
  64. </div>
  65. </div>
  66. </div>
  67. <div class="course-action l">
  68. <a class="pay-now" href="/pay/cashier?trade_number=2108100232047715">立即支付</a>
  69. <a class="order-cancel" href="javascript:void(0);">取消订单</a>
  70. </div>
  71. </div>
  72. </li>
  73. <li data-flag="2107312108465190">
  74. <p class="myOrder-number">
  75. <i class="imv2-receipt"></i>订单编号:2107312108465190
  76. <span class="date">2021-07-31 21:08:46</span>
  77. <i class="imv2-delete js-order-del" title="删除订单"></i>
  78. <router-link to="/user/help" target="_blank" class="myfeedback r">售后帮助</router-link>
  79. </p>
  80. <div class="myOrder-course clearfix">
  81. <dl class="course-del l">
  82. <dd class="clearfix">
  83. <router-link to="/course/301" class="l">
  84. <img class="l" src="" width="160" height="90">
  85. </router-link>
  86. <div class="del-box l">
  87. <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
  88. <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
  89. <router-link to="/course/301"><p class="course-name">Hadoop 系统入门+核心精讲</p></router-link>
  90. <p class="price-btn-box clearfix">
  91. <!-- 如果有优惠券 -->
  92. <span class="l truepay-text">实付</span>
  93. <span class="l course-little-price">¥288.00</span>
  94. </p>
  95. </div>
  96. </dd>
  97. <dd class="clearfix">
  98. <router-link to="/course/464" class="l">
  99. <img class="l" src="" width="160" height="90">
  100. </router-link>
  101. <div class="del-box l">
  102. <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
  103. <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
  104. <router-link to="/course/464"><p class="course-name">Kubernetes 入门到进阶实战,系统性掌握 K8s 生产实践</p></router-link>
  105. <p class="price-btn-box clearfix">
  106. <!-- 如果有优惠券 -->
  107. <span class="l truepay-text">实付</span>
  108. <span class="l course-little-price">¥299.00</span>
  109. </p>
  110. </div>
  111. </dd>
  112. <dd class="clearfix">
  113. <router-link to="/course/501" class="l">
  114. <img class="l" src="" width="160" height="90">
  115. </router-link>
  116. <div class="del-box l">
  117. <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
  118. <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
  119. <router-link to="/course/501"><p class="course-name">2021必修 CSS架构系统精讲 理论+实战玩转蘑菇街</p></router-link>
  120. <p class="price-btn-box clearfix">
  121. <!-- 如果有优惠券 -->
  122. <span class="l truepay-text">实付</span>
  123. <span class="l course-little-price">¥288.00</span>
  124. </p>
  125. </div>
  126. </dd>
  127. <dd class="clearfix">
  128. <router-link to="/course/503" class="l">
  129. <img class="l" src="" width="160" height="90">
  130. </router-link>
  131. <div class="del-box l">
  132. <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
  133. <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
  134. <router-link to="/course/503"><p class="course-name">Vue3开发企业级音乐Web App 明星讲师带你学习大厂高质量代码</p></router-link>
  135. <p class="price-btn-box clearfix">
  136. <!-- 如果有优惠券 -->
  137. <span class="l truepay-text">实付</span>
  138. <span class="l course-little-price">¥448.00</span>
  139. </p>
  140. </div>
  141. </dd>
  142. <dd class="clearfix">
  143. <router-link to="/course/522" class="l">
  144. <img class="l" src="" width="160" height="90">
  145. </router-link>
  146. <div class="del-box l">
  147. <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
  148. <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
  149. <router-link to="/course/522"><p class="course-name"> Spring Cloud / Alibaba 微服务架构实战,从架构设计到开发实践,手把手实现</p></router-link>
  150. <p class="price-btn-box clearfix">
  151. <!-- 如果有优惠券 -->
  152. <span class="l truepay-text">实付</span>
  153. <span class="l course-little-price">¥428.00</span>
  154. </p>
  155. </div>
  156. </dd>
  157. </dl>
  158. <!-- 使用优惠券 -->
  159. <div class="course-money l pt15">
  160. <div class="wrap">
  161. <div class="type-box clearfix mb10">
  162. <p class="type-text l">原价</p>
  163. <p class="type-price l line-though">
  164. <span class="RMB">¥</span>
  165. 1811.00
  166. </p>
  167. </div>
  168. <div class="type-box clearfix mb10">
  169. <p class="type-text l">折扣</p>
  170. <p class="type-price l">
  171. -
  172. <span class="RMB">¥</span>
  173. 60.00
  174. </p>
  175. </div>
  176. <div class="total-box clearfix">
  177. <p class="type-text l">实付</p>
  178. <p class="type-price l">
  179. <span class="RMB">¥</span>
  180. 1751.00
  181. </p>
  182. </div>
  183. </div>
  184. </div>
  185. <div class="course-action l">
  186. <p class="order-close">已过期</p>
  187. </div>
  188. </div>
  189. </li>
  190. </ul>
  191. </div>
  192. <div class="page" style="text-align: center">
  193. <el-pagination background layout="prev, pager, next" :total="1000"></el-pagination>
  194. </div>
  195. </div>
  196. </template>
  197. <script setup>
  198. </script>
  199. <style scoped>
  200. .l {
  201. float: left;
  202. }
  203. .r {
  204. float: right;
  205. }
  206. .clearfix:after {
  207. content: '\0020';
  208. display: block;
  209. height: 0;
  210. clear: both;
  211. visibility: hidden;
  212. }
  213. /*****/
  214. .right-container {
  215. width: 1284px;
  216. }
  217. .right-container .right-title {
  218. margin-bottom: 24px
  219. }
  220. .right-container .right-title::after {
  221. content: '';
  222. clear: both;
  223. display: block
  224. }
  225. .right-container .right-title h2 {
  226. margin-right: 24px;
  227. float: left;
  228. font-size: 16px;
  229. color: #07111b;
  230. line-height: 32px;
  231. font-weight: 700
  232. }
  233. .right-container .right-title ul {
  234. float: left
  235. }
  236. .right-container .right-title ul:before {
  237. float: left;
  238. margin-top: 2px;
  239. margin-right: 20px;
  240. content: "|";
  241. color: #d9dde1
  242. }
  243. .right-container .right-title ul li {
  244. float: left;
  245. width: 95px;
  246. line-height: 32px;
  247. text-align: center;
  248. font-size: 14px
  249. }
  250. .right-container .right-title ul li.action {
  251. background: #4d555d;
  252. border-radius: 16px
  253. }
  254. .right-container .right-title ul li.action a {
  255. color: #fff
  256. }
  257. .right-container .right-title ul li i {
  258. padding-left: 5px;
  259. font-style: normal
  260. }
  261. .right-container .right-title span {
  262. position: relative;
  263. float: right;
  264. color: #93999f;
  265. font-size: 14px;
  266. cursor: pointer;
  267. width: 128px;
  268. line-height: 32px
  269. }
  270. .right-container .right-title span i {
  271. float: left;
  272. margin-top: 8px;
  273. margin-left: 28px;
  274. margin-right: 4px;
  275. font-size: 16px
  276. }
  277. .right-container .right-title span a {
  278. display: block
  279. }
  280. .right-container .right-title span.action {
  281. background: #4d555d;
  282. border-radius: 16px
  283. }
  284. .right-container .right-title span.action a {
  285. color: #fff
  286. }
  287. .myOrder {
  288. width: 100%
  289. }
  290. .myOrder-list li {
  291. padding: 32px;
  292. padding-top: 0;
  293. box-shadow: 0 2px 8px 2px rgba(0,0,0,.1);
  294. margin-bottom: 24px;
  295. background: #fff;
  296. border-radius: 8px;
  297. position: relative
  298. }
  299. .myOrder-list li dd {
  300. margin-top: 24px;
  301. padding-top: 24px;
  302. position: relative;
  303. box-sizing: border-box;
  304. border-top: 1px solid #d9dde1
  305. }
  306. .myOrder-list li dd a {
  307. display: block
  308. }
  309. .myOrder-list li dd:first-child {
  310. border-top: none;
  311. margin-top: 0;
  312. padding-top: 0
  313. }
  314. .myOrder-list li:hover {
  315. -webkit-box-shadow: 0 2px 16px 2px rgba(0,0,0,.1);
  316. -moz-box-shadow: 0 2px 16px 2px rgba(0,0,0,.1);
  317. box-shadow: 0 2px 16px 2px rgba(0,0,0,.1)
  318. }
  319. .myOrder-list li:hover .myOrder-number a,.myOrder-list li:hover i.imv2-delete {
  320. display: block
  321. }
  322. .del-box {
  323. margin-left: 16px;
  324. width: 510px
  325. }
  326. .del-box .course-name {
  327. word-break: break-word;
  328. color: #07111b;
  329. font-size: 16px;
  330. margin-bottom: 8px;
  331. line-height: 22px
  332. }
  333. .del-box .price-btn-box {
  334. font-size: 14px;
  335. line-height: 14px
  336. }
  337. .del-box .price-btn-box .truepay-text {
  338. color: #93999f;
  339. margin-right: 5px
  340. }
  341. .del-box .price-btn-box .course-little-price {
  342. color: #f01414
  343. }
  344. .myOrder-number {
  345. padding: 28px 0 19px;
  346. font-weight: 700;
  347. color: #4d555d;
  348. border-bottom: 1px solid #b7bbbf;
  349. font-size: 14px;
  350. line-height: 14px;
  351. box-sizing: border-box
  352. }
  353. .myOrder-number a,.myOrder-number span {
  354. color: #93999f;
  355. font-weight: 500;
  356. margin-left: 24px
  357. }
  358. .myOrder-number a {
  359. display: none
  360. }
  361. .myOrder-number a:hover {
  362. color: #4d555d
  363. }
  364. .myOrder-number i.imv2-delete,.myOrder-number i.imv2-receipt {
  365. float: left;
  366. margin-top: -2px;
  367. margin-right: 10px;
  368. font-size: 16px;
  369. color: #f01414
  370. }
  371. .myOrder-number i.imv2-delete {
  372. float: right;
  373. margin-left: 28px;
  374. color: #93999f;
  375. cursor: pointer;
  376. display: none
  377. }
  378. .myOrder-number i.imv2-delete:hover {
  379. color: #4d555d
  380. }
  381. .myOrder-course {
  382. position: relative;
  383. margin-top: 25px
  384. }
  385. .course-money {
  386. width: 250px;
  387. height: 100%;
  388. text-align: center;
  389. color: #93999f;
  390. font-size: 16px;
  391. box-sizing: border-box;
  392. line-height: 16px
  393. }
  394. .course-money .wrap {
  395. display: inline-block
  396. }
  397. .course-money .RMB {
  398. font-size: 14px;
  399. vertical-align: top;
  400. line-height: 14px
  401. }
  402. .course-money .type-box {
  403. line-height: 14px;
  404. text-align: left
  405. }
  406. .course-money .type-box .type-price,.course-money .type-box .type-text {
  407. font-size: 16px;
  408. color: #93999f
  409. }
  410. .course-money .type-box .type-price .RMB,.course-money .type-box .type-text .RMB {
  411. font-size: 14px;
  412. display: inline-block;
  413. position: relative;
  414. top: -1px;
  415. vertical-align: top;
  416. line-height: 14px
  417. }
  418. .course-money .type-box .line-though {
  419. text-decoration: line-through
  420. }
  421. .course-money .type-box .type-text {
  422. margin-right: 5px
  423. }
  424. .course-money .total-box .type-text {
  425. font-size: 14px;
  426. color: #93999f;
  427. margin-right: 5px
  428. }
  429. .course-money .total-box .type-price {
  430. color: #f01414
  431. }
  432. .course-money .mb10 {
  433. margin-bottom: 10px
  434. }
  435. .course-money.presale .type-box {
  436. line-height: 18px;
  437. margin-bottom: 4px
  438. }
  439. .course-money.presale .type-box .type-text {
  440. color: #1c1f21
  441. }
  442. .course-money.presale .type-box .type-price .RMB {
  443. vertical-align: baseline
  444. }
  445. .course-action {
  446. position: absolute;
  447. top: 0;
  448. width: 180px;
  449. height: 100%;
  450. border-left: 1px solid #d9dde1;
  451. right: 0;
  452. text-align: center
  453. }
  454. .course-action .pay-now {
  455. margin: 12px auto;
  456. display: block;
  457. width: 120px;
  458. height: 36px;
  459. color: #fff;
  460. background: rgba(240,20,20,.8);
  461. border-radius: 18px;
  462. line-height: 36px
  463. }
  464. .course-action .pay-now:hover {
  465. background-color: #f01414
  466. }
  467. .course-action .order-cancel {
  468. color: #93999f;
  469. display: block;
  470. font-size: 14px;
  471. line-height: 14px
  472. }
  473. .course-action .order-cancel:hover {
  474. color: #4d555d
  475. }
  476. .course-action .order-close {
  477. color: #93999f;
  478. margin-top: 36px;
  479. line-height: 14px
  480. }
  481. .course-action.order-recover .order-close {
  482. margin-top: 22px
  483. }
  484. .course-del {
  485. width: 740px;
  486. border-right: 1px solid #d9dde1;
  487. position: relative
  488. }
  489. </style>

路由,src/router/index.js,代码:

  1. import {createRouter, createWebHistory, createWebHashHistory} from 'vue-router'
  2. import store from "../store";
  3. // 路由列表
  4. const routes = [
  5. {
  6. meta:{
  7. title: "luffy2.0-站点首页",
  8. keepAlive: true
  9. },
  10. path: '/', // uri访问地址
  11. name: "Home",
  12. component: ()=> import("../views/Home.vue")
  13. },
  14. {
  15. meta:{
  16. title: "luffy2.0-用户登录",
  17. keepAlive: true
  18. },
  19. path:'/login', // uri访问地址
  20. name: "Login",
  21. component: ()=> import("../views/Login.vue")
  22. },
  23. {
  24. meta:{
  25. title: "luffy2.0-用户注册",
  26. keepAlive: true
  27. },
  28. path: '/register',
  29. name: "Register", // 路由名称
  30. component: ()=> import("../views/Register.vue"), // uri绑定的组件页面
  31. },
  32. {
  33. meta:{
  34. title: "luffy2.0-个人中心",
  35. keepAlive: true,
  36. authorization: true,
  37. },
  38. path: '/user',
  39. name: "User",
  40. component: ()=> import("../views/User.vue"),
  41. children: [
  42. {
  43. meta:{
  44. title: "luffy2.0-个人信息",
  45. keepAlive: true,
  46. authorization: true,
  47. },
  48. path: '',
  49. name: "UserInfo",
  50. component: ()=> import("../components/user/Info.vue"),
  51. },
  52. {
  53. meta:{
  54. title: "luffy2.0--我的订单",
  55. keepAlive: true,
  56. authorization: true,
  57. },
  58. path: 'order',
  59. name: "UserOrder",
  60. component: ()=> import("../components/user/Order.vue"),
  61. },
  62. ]
  63. },
  64. {
  65. meta:{
  66. title: "luffy2.0-课程列表",
  67. keepAlive: true,
  68. },
  69. path: '/project',
  70. name: "Course",
  71. component: ()=> import("../views/Course.vue"),
  72. },
  73. {
  74. meta:{
  75. title: "luffy2.0-课程详情",
  76. keepAlive: true
  77. },
  78. path: '/project/:id', // :id vue的路径参数,代表了课程的ID
  79. name: "Info",
  80. component: ()=> import("../views/Info.vue"),
  81. },
  82. {
  83. meta:{
  84. title: "luffy2.0-购物车",
  85. keepAlive: true
  86. },
  87. path: '/cart',
  88. name: "Cart",
  89. component: ()=> import("../views/Cart.vue"),
  90. },{
  91. meta:{
  92. title: "确认下单",
  93. keepAlive: true
  94. },
  95. path: '/order',
  96. name: "Order",
  97. component: ()=> import("../views/Order.vue"),
  98. },
  99. {
  100. meta:{
  101. title: "支付成功",
  102. keepAlive: true
  103. },
  104. path: '/alipay',
  105. name: "PaySuccess",
  106. component: ()=> import("../views/AliPaySuccess.vue"),
  107. },
  108. ]
  109. // 路由对象实例化
  110. const router = createRouter({
  111. // history, 指定路由的模式
  112. history: createWebHistory(),
  113. // 路由列表
  114. routes,
  115. });
  116. // 导航守卫
  117. router.beforeEach((to, from, next)=>{
  118. document.title=to.meta.title
  119. // 登录状态验证
  120. if (to.meta.authorization && !store.getters.getUserInfo) {
  121. next({"name": "Login"})
  122. }else{
  123. next()
  124. }
  125. })
  126. // 暴露路由对象
  127. export default router

我的订单

服务端提供当前用户的订单列表api接口

orders/views.py,代码:

  1. from rest_framework.generics import CreateAPIView
  2. from rest_framework.permissions import IsAuthenticated
  3. from rest_framework.views import APIView
  4. from rest_framework.response import Response
  5. from rest_framework.generics import ListAPIView
  6. from .serializers import OrderListModelSerializer
  7. from .paginations import OrderListPageNumberPagination
  8. from .models import Order
  9. from .serializers import OrderModelSerializer
  10. # 中间代码省略。。。。
  11. # 中间代码省略。。。。
  12. class OrderPayChoicesAPIView(APIView):
  13. def get(self,request):
  14. """订单过滤过滤选项"""
  15. return Response(Order.status_choices)
  16. class OrderListAPIView(ListAPIView):
  17. """当前登录用户的订单列表"""
  18. permission_classes = [IsAuthenticated]
  19. serializer_class = OrderListModelSerializer
  20. pagination_class = OrderListPageNumberPagination
  21. def get_queryset(self):
  22. user = self.request.user # 获取当前登录用户
  23. query = Order.objects.filter(user=user, is_deleted=False, is_show=True)
  24. order_status = int(self.request.query_params.get("status", -1))
  25. status_list = [item[0] for item in Order.status_choices]
  26. if order_status in status_list:
  27. query = query.filter(order_status=order_status)
  28. return query.order_by("-id").all()

orders/paginations.py,代码:

  1. from rest_framework.pagination import PageNumberPagination
  2. class OrderListPageNumberPagination(PageNumberPagination):
  3. """订单列表分页器"""
  4. page_size = 5 # 每一页显示数据量
  5. page_size_query_param = "size" # 地址栏上的页码
  6. max_page_size = 20 # 允许客户端通过size参数修改的每页最大数据量
  7. page_query_param = "page" # 地址栏上的页面参数名

orders/serializers.py,代码:

  1. class OrderDetailMdoelSerializer(serializers.ModelSerializer):
  2. """订单详情序列化器"""
  3. # 通过source修改数据源,可以把需要调用的部分外键字段提取到当前序列化器中
  4. course_id = serializers.IntegerField(source="course.id")
  5. course_name = serializers.CharField(source="course.name")
  6. course_cover = serializers.ImageField(source="course.course_cover")
  7. class Meta:
  8. model = OrderDetail
  9. fields = ["id", "price", "real_price", "discount_name", "course_id", "course_name", "course_cover"]
  10. class OrderListModelSerializer(serializers.ModelSerializer):
  11. """订单列表序列化器"""
  12. order_courses = OrderDetailMdoelSerializer(many=True)
  13. class Meta:
  14. model = Order
  15. fields = ["id", "order_number", "total_price", "real_price", "pay_time", "created_time", "credit", "coupon",
  16. "pay_type", "order_status", "order_courses"]

orders/models.py,代码:

  1. class Order(BaseModel):
  2. """订单基本信息模型"""
  3. # //.... 中间代码省略
  4. # //.... 中间代码省略
  5. # //.... 中间代码省略
  6. def coupon(self):
  7. """当前订单关联的优惠券信息"""
  8. coupon_related = self.to_coupon.first()
  9. if coupon_related:
  10. return {
  11. "id": coupon_related.coupon.id,
  12. "name": coupon_related.coupon.name,
  13. "sale": coupon_related.coupon.sale,
  14. "discount": coupon_related.coupon.discount,
  15. "condition": coupon_related.coupon.condition,
  16. }
  17. return {}

orders/urls.py,代码:

  1. from django.urls import path
  2. from . import views
  3. urlpatterns = [
  4. path("", views.OrderCreateAPIView.as_view()),
  5. path("pay/choices/", views.OrderPayChoicesAPIView.as_view()),
  6. path("list/", views.OrderListAPIView.as_view()),
  7. ]

客户端展示订单列表

api/order.js,代码:

  1. import http from "../utils/http";
  2. import {reactive} from "vue";
  3. const order = reactive({
  4. // ... 中间代码省略
  5. // ... 中间代码省略
  6. // ... 中间代码省略
  7. order_status: -1, // 个人中心的默认显示的订单状态选项
  8. order_status_chioces:[], // 个人中心的订单支付状态选项
  9. page: 1, // 个人中心的订单列表对应的页码
  10. size: 5, // 个人中心的订单列表对应的单页数据量
  11. order_list:[], // 个人中心的订单列表
  12. count: 0, // 个人中心的订单列表的总数据量
  13. // ... 中间代码省略
  14. // ... 中间代码省略
  15. // ... 中间代码省略
  16. get_order_status(){
  17. // 获取订单状态选项
  18. return http.get('/orders/pay/status/')
  19. },
  20. get_order_list(token){
  21. // 获取当前登录用户的订单列表[分页显示]
  22. return http.get('/orders/list/', {
  23. params: {
  24. page: this.page,
  25. size: this.size,
  26. status: this.order_status,
  27. },
  28. headers: {
  29. Authorization: "jwt " + token,
  30. }
  31. })
  32. }
  33. });
  34. export default order;

src/components/user/Order.vue,代码:

  1. <template>
  2. <div class="right-container l">
  3. <div class="right-title">
  4. <h2>我的订单</h2>
  5. <ul>
  6. <li :class="{action: order.order_status===-1}"><a href="" @click.prevent="order.order_status=-1">全部<i class="js-all-num" v-if="order.order_status===-1">{{order.count}}</i></a></li>
  7. <li :class="{action: order.order_status===status[0]}" v-for="status in order.order_status_chioces">
  8. <a href="" @click.prevent="order.order_status=status[0]">{{status[1]}}<i class="js-all-num" v-if="order.order_status===status[0]">{{order.count}}</i></a>
  9. </li>
  10. </ul>
  11. </div>
  12. <div class="myOrder">
  13. <ul class="myOrder-list">
  14. <li v-for="order_info in order.order_list">
  15. <p class="myOrder-number">
  16. <i class="imv2-receipt"></i>订单编号:{{order_info.order_number}}
  17. <span class="date">{{order_info.created_time.replace("T", " ").split(".")[0]}}</span>
  18. <span class="imv2-delete js-order-del">删除订单</span>
  19. <router-link to="/user/help" target="_blank" class="myfeedback r">售后帮助</router-link>
  20. </p>
  21. <div class="myOrder-course clearfix">
  22. <dl class="course-del l" v-for="course_info in order_info.order_courses">
  23. <dd class="clearfix">
  24. <router-link :to="`/project/${course_info.course_id}`" class="l"><img class="l" :src="course_info.course_cover" width="160" height="90"></router-link>
  25. <div class="del-box l">
  26. <router-link :to="`/project/${course_info.course_id}`"><p class="course-name">{{course_info.course_name}}</p></router-link>
  27. <p class="price-btn-box clearfix">
  28. <!-- 如果有优惠券 -->
  29. <span class="l truepay-text" v-if="course_info.price > course_info.real_price">原价</span>
  30. <span class="l line-though clearfix" style="float: none" v-if="course_info.price > course_info.real_price">¥{{course_info.price}}</span>
  31. <span class="l truepay-text" v-if="course_info.price > course_info.real_price">折扣</span>
  32. <span class="l line-though clearfix" style="float: none" v-if="course_info.price > course_info.real_price">¥{{parseFloat(course_info.price - course_info.real_price).toFixed(2)}}</span>
  33. <span class="l truepay-text">实付</span>
  34. <span class="l course-little-price">¥{{course_info.real_price}}</span>
  35. </p>
  36. </div>
  37. </dd>
  38. </dl>
  39. <!-- 使用优惠券 -->
  40. <div class="course-money l pt15">
  41. <div class="wrap">
  42. <div class="type-box clearfix mb10">
  43. <p class="type-text l">订单总价</p>
  44. <p class="type-price l line-though"><span class="RMB">¥</span>{{order_info.total_price}}</p>
  45. </div>
  46. <div class="type-box clearfix mb10" v-if="order_info.total_price > order_info.real_price">
  47. <p class="type-text l" v-if="order_info.credit>0">积分折扣</p>
  48. <p class="type-text l" v-if="order_info.coupon.id">优惠券折扣</p>
  49. <p class="type-price l">-<span class="RMB">¥</span>{{parseFloat(order_info.total_price - order_info.real_price).toFixed(2)}}</p>
  50. </div>
  51. <div class="total-box clearfix">
  52. <p class="type-text l">订单实付</p>
  53. <p class="type-price l"><span class="RMB">¥</span>{{order_info.real_price}}</p>
  54. </div>
  55. </div>
  56. </div>
  57. <div class="course-action l" v-if="order_info.order_status === 0">
  58. <a class="pay-now" href="" @click.prevent="pay_now(order_info)">立即支付</a>
  59. <a class="order-cancel" href="" @click.prevent="pay_cancel(order_info)">取消订单</a>
  60. </div>
  61. <div class="course-action l" v-else-if="order_info.order_status === 1">
  62. <a class="pay-now" href="" @click.prevent="evaluate_now(order_info)">立即评价</a>
  63. <a class="order-cancel" href="" @click.prevent="order_refund(order_info)">申请退款</a>
  64. </div>
  65. <div class="course-action l" v-else-if="order_info.order_status === 2">
  66. <a class="pay-now" href="" @click.prevent="delete_order(order_info)">删除订单</a>
  67. </div>
  68. <div class="course-action l" v-else-if="order_info.order_status === 3">
  69. <a class="pay-now" href="" @click.prevent="recovery_now(order_info)">订单恢复</a>
  70. <a class="pay-now" href="" @click.prevent="delete_order(order_info)">删除订单</a>
  71. </div>
  72. </div>
  73. </li>
  74. </ul>
  75. </div>
  76. <div class="page" style="text-align: center">
  77. <el-pagination
  78. background
  79. layout="sizes, prev, pager, next, jumper"
  80. :total="order.count"
  81. :page-sizes="[5, 10, 15, 20]"
  82. :page-size="order.size"
  83. @current-change="current_page"
  84. @size-change="current_size"
  85. ></el-pagination>
  86. </div>
  87. </div>
  88. </template>
  1. <script setup>
  2. import {watch} from "vue";
  3. import order from "../../api/order"
  4. const getOrderStatus = ()=>{
  5. // 获取订单状态选项
  6. order.get_order_status().then(response=>{
  7. order.order_status_chioces = response.data;
  8. })
  9. }
  10. getOrderStatus()
  11. const getOrderList = ()=>{
  12. // 获取当前登录用户的订单列表
  13. let token = sessionStorage.token || localStorage.token;
  14. order.get_order_list(token).then(response=>{
  15. order.order_list = response.data.results
  16. order.count = response.data.count
  17. })
  18. }
  19. getOrderList()
  20. let pay_now = (order_info)=>{
  21. // 订单继续支付
  22. }
  23. let pay_cancel = (order_info)=>{
  24. // 取消订单
  25. }
  26. let evaluate_now = (order_info)=>{
  27. // 订单评价
  28. }
  29. let order_refund = (order_info)=>{
  30. // 申请退款
  31. }
  32. let delete_order = (order_info)=>{
  33. // 删除订单
  34. }
  35. let recovery_now = (order)=>{
  36. // 恢复订单
  37. }
  38. // 切换页码
  39. let current_page = (page)=>{
  40. order.page = page;
  41. }
  42. // 切换分页数据量
  43. let current_size = (size)=>{
  44. order.size = size;
  45. }
  46. // 监听页码
  47. watch(
  48. ()=>order.page,
  49. ()=>{
  50. getOrderList()
  51. }
  52. )
  53. // 监听页面数据量大小
  54. watch(
  55. ()=>order.size,
  56. ()=>{
  57. order.page = 1;
  58. getOrderList()
  59. }
  60. )
  61. // 监听订单状态选项
  62. watch(
  63. ()=>order.order_status,
  64. ()=>{
  65. order.page = 1;
  66. getOrderList()
  67. }
  68. )
  69. </script>

提交代码版本

  1. cd /home/moluo/Desktop/luffycity
  2. git add .
  3. git commit -m "feature: 客户端展示用户中心并显示当前用户的订单列表"
  4. git push

订单状态切换

取消订单

服务端提供取消订单的API接口

  1. import logging
  2. from rest_framework.generics import CreateAPIView
  3. from rest_framework.permissions import IsAuthenticated
  4. from rest_framework.views import APIView
  5. from rest_framework.response import Response
  6. from rest_framework.generics import ListAPIView
  7. from rest_framework.viewsets import ViewSet
  8. from rest_framework import status
  9. from django.db import transaction
  10. from .serializers import OrderListModelSerializer
  11. from .paginations import OrderListPageNumberPagination
  12. from .models import Order
  13. from .serializers import OrderModelSerializer
  14. from coupon.services import add_coupon_to_redis
  15. logger = logging.getLogger("django")
  16. # Create your views here.
  17. class OrderCreateAPIView(CreateAPIView):
  18. """创建订单"""
  19. permission_classes = [IsAuthenticated]
  20. queryset = Order.objects.all()
  21. serializer_class = OrderModelSerializer
  22. class OrderPayChoicesAPIView(APIView):
  23. def get(self,request):
  24. """订单过滤过滤选项"""
  25. return Response(Order.status_choices)
  26. class OrderListAPIView(ListAPIView):
  27. """当前登录用户的订单列表"""
  28. permission_classes = [IsAuthenticated]
  29. serializer_class = OrderListModelSerializer
  30. pagination_class = OrderListPageNumberPagination
  31. def get_queryset(self):
  32. user = self.request.user # 获取当前登录用户
  33. query = Order.objects.filter(user=user, is_deleted=False, is_show=True)
  34. order_status = int(self.request.query_params.get("status", -1))
  35. status_list = [item[0] for item in Order.status_choices]
  36. if order_status in status_list:
  37. query = query.filter(order_status=order_status)
  38. return query.order_by("-id").all()
  39. class OrderViewSet(ViewSet):
  40. permission_classes = [IsAuthenticated]
  41. def pay_cancel(self, request, pk):
  42. """取消订单"""
  43. try:
  44. order = Order.objects.get(pk=pk, order_status=0)
  45. except:
  46. return Response({"eremsg": "当前订单记录不存在或不能取消!"}, status=status.HTTP_400_BAD_REQUEST)
  47. with transaction.atomic():
  48. save_id = transaction.savepoint()
  49. try:
  50. # 1. 查询当前订单是否使用了积分,如果有则恢复
  51. if order.credit > 0:
  52. order.user.credit += order.credit
  53. order.user.save()
  54. # 2. 查询当前订单是否使用了优惠券,如果有则恢复
  55. obj = order.to_coupon.first()
  56. if obj:
  57. add_coupon_to_redis(obj)
  58. # 3. 切换当前订单为取消状态
  59. order.order_status = 2
  60. order.save()
  61. return Response({"error": "当前订单已取消!"})
  62. except Exception as e:
  63. transaction.savepoint_rollback(save_id)
  64. logger.error(f"订单无法取消!发生未知错误!{e}")
  65. return Response({"errmsg": "当前订单无法取消,请联系客服工作人员!"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

coupon/services.py,代码:

  1. import json
  2. from datetime import datetime
  3. from django_redis import get_redis_connection
  4. from courses.models import Course
  5. def get_user_coupon_list(user_id):
  6. """获取指定用户拥有的所有优惠券列表"""
  7. redis = get_redis_connection("coupon")
  8. coupon_list = redis.keys(f"{user_id}:*")
  9. coupon_id_list = [item.decode() for item in coupon_list]
  10. coupon_data = []
  11. for coupon_key in coupon_id_list:
  12. coupon_item = {"user_coupon_id": int(coupon_key.split(":")[-1])}
  13. coupon_hash = redis.hgetall(coupon_key)
  14. for key, value in coupon_hash.items():
  15. key = key.decode()
  16. value = value.decode()
  17. if key in ["to_course", "to_category", "to_direction"]:
  18. value = json.loads(value)
  19. coupon_item[key] = value
  20. coupon_data.append(coupon_item)
  21. return coupon_data
  22. def get_user_enable_coupon_list(user_id):
  23. """
  24. 获取指定用户本次下单的可用优惠券列表
  25. 根据当前本次客户端购买商品课程进行比较,获取用户的当前可用优惠券。
  26. """
  27. redis = get_redis_connection("cart")
  28. # 先获取所有的优惠券列表
  29. coupon_data = get_user_coupon_list(user_id)
  30. # 获取指定用户的购物车中的勾选商品[与优惠券的适用范围进行比对,找出能用的优惠券]
  31. cart_hash = redis.hgetall(f"cart_{user_id}")
  32. # 获取被勾选的商品课程的ID列表
  33. course_id_list = {int(key.decode()) for key, value in cart_hash.items() if value == b'1'}
  34. # 获取被勾选的商品课程的模型对象列表
  35. course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()
  36. category_id_list = set()
  37. direction_id_list = set()
  38. for course in course_list:
  39. # 获取被勾选的商品课程的父类课程分类id列表,并保证去重
  40. category_id_list.add(int(course.category.id))
  41. # # 获取被勾选的商品课程的父类学习方向id列表,并保证去重
  42. direction_id_list.add(int(course.direction.id))
  43. # 创建一个列表用于保存所有的可用优惠券
  44. enable_coupon_list = []
  45. for item in coupon_data:
  46. coupon_type = int(item.get("coupon_type"))
  47. if coupon_type == 0:
  48. # 通用类型优惠券
  49. item["enable_course"] = "__all__"
  50. enable_coupon_list.append(item)
  51. elif coupon_type == 3:
  52. # 指定课程优惠券
  53. coupon_course = {int(course_item["course__id"]) for course_item in item.get("to_course")}
  54. # 并集处理
  55. ret = course_id_list & coupon_course
  56. if len(ret) > 0:
  57. item["enable_course"] = {int(course.id) for course in course_list if course.id in ret}
  58. enable_coupon_list.append(item)
  59. elif coupon_type == 2:
  60. # 指定课程分配优惠券
  61. coupon_category = {int(category_item["category__id"]) for category_item in item.get("to_category")}
  62. # 并集处理
  63. ret = category_id_list & coupon_category
  64. if len(ret) > 0:
  65. item["enable_course"] = {int(course.id) for course in course_list if course.category.id in ret}
  66. enable_coupon_list.append(item)
  67. elif coupon_type == 1:
  68. # 指定学习方向的优惠券
  69. coupon_direction = {int(direction_item["direction__id"]) for direction_item in item.get("to_direction")}
  70. # 并集处理
  71. ret = direction_id_list & coupon_direction
  72. if len(ret) > 0:
  73. item["enable_course"] = {int(course.id) for course in course_list if course.direction.id in ret}
  74. enable_coupon_list.append(item)
  75. return enable_coupon_list
  76. def add_coupon_to_redis(obj):
  77. """
  78. 添加优惠券使用记录到redis中
  79. """
  80. redis = get_redis_connection("coupon")
  81. # 记录优惠券信息到redis中
  82. pipe = redis.pipeline()
  83. pipe.multi()
  84. pipe.hset(f"{obj.user.id}:{obj.id}", "coupon_id", obj.coupon.id)
  85. pipe.hset(f"{obj.user.id}:{obj.id}", "name", obj.coupon.name)
  86. pipe.hset(f"{obj.user.id}:{obj.id}", "discount", obj.coupon.discount)
  87. pipe.hset(f"{obj.user.id}:{obj.id}", "get_discount_display", obj.coupon.get_discount_display())
  88. pipe.hset(f"{obj.user.id}:{obj.id}", "coupon_type", obj.coupon.coupon_type)
  89. pipe.hset(f"{obj.user.id}:{obj.id}", "get_coupon_type_display", obj.coupon.get_coupon_type_display())
  90. pipe.hset(f"{obj.user.id}:{obj.id}", "start_time", obj.coupon.start_time.strftime("%Y-%m-%d %H:%M:%S"))
  91. pipe.hset(f"{obj.user.id}:{obj.id}", "end_time", obj.coupon.end_time.strftime("%Y-%m-%d %H:%M:%S"))
  92. pipe.hset(f"{obj.user.id}:{obj.id}", "get_type", obj.coupon.get_type)
  93. pipe.hset(f"{obj.user.id}:{obj.id}", "get_get_type_display", obj.coupon.get_get_type_display())
  94. pipe.hset(f"{obj.user.id}:{obj.id}", "condition", obj.coupon.condition)
  95. pipe.hset(f"{obj.user.id}:{obj.id}", "sale", obj.coupon.sale)
  96. pipe.hset(f"{obj.user.id}:{obj.id}", "to_direction",
  97. json.dumps(list(obj.coupon.to_direction.values("direction__id", "direction__name"))))
  98. pipe.hset(f"{obj.user.id}:{obj.id}", "to_category",
  99. json.dumps(list(obj.coupon.to_category.values("category__id", "category__name"))))
  100. pipe.hset(f"{obj.user.id}:{obj.id}", "to_course",
  101. json.dumps(list(obj.coupon.to_course.values("course__id", "course__name"))))
  102. # 设置当前优惠券的有效期
  103. pipe.expire(f"{obj.user.id}:{obj.id}", int(obj.coupon.end_time.timestamp() - datetime.now().timestamp()))
  104. pipe.execute()

对于之前在admin站点中保存优惠券使用都redis中的操作,也可以调用上面的代码,coupon/admin.py,代码:

  1. import json
  2. from datetime import datetime
  3. from django_redis import get_redis_connection
  4. from django.contrib import admin
  5. from .models import Coupon, CouponDirection, CouponCourseCat, CouponCourse, CouponLog
  6. from .services import add_coupon_to_redis
  7. # Register your models here.
  8. class CouponDirectionInLine(admin.TabularInline): # admin.StackedInline
  9. """学习方向的内嵌类"""
  10. model = CouponDirection
  11. fields = ["id", "direction"]
  12. class CouponCourseCatInLine(admin.TabularInline): # admin.StackedInline
  13. """课程分类的内嵌类"""
  14. model = CouponCourseCat
  15. fields = ["id", "category"]
  16. class CouponCourseInLine(admin.TabularInline): # admin.StackedInline
  17. """课程信息的内嵌类"""
  18. model = CouponCourse
  19. fields = ["id", "course"]
  20. class CouponModelAdmin(admin.ModelAdmin):
  21. """优惠券的模型管理器"""
  22. list_display = ["id", "name", "start_time", "end_time", "total", "has_total", "coupon_type", "get_type", ]
  23. inlines = [CouponDirectionInLine, CouponCourseCatInLine, CouponCourseInLine]
  24. admin.site.register(Coupon, CouponModelAdmin)
  25. class CouponLogModelAdmin(admin.ModelAdmin):
  26. """优惠券发放和使用日志"""
  27. list_display = ["id", "user", "coupon", "order", "use_time", "use_status"]
  28. def save_model(self, request, obj, form, change):
  29. """
  30. 保存或更新记录时自动执行的钩子
  31. request: 本次客户端提交的请求对象
  32. obj: 本次操作的模型实例对象
  33. form: 本次客户端提交的表单数据
  34. change: 值为True,表示更新数据,值为False,表示添加数据
  35. """
  36. obj.save()
  37. # 同步记录到redis中
  38. redis = get_redis_connection("coupon")
  39. # print(obj.use_status , obj.use_time)
  40. if obj.use_status == 0 and obj.use_time == None:
  41. # 记录优惠券信息到redis中
  42. add_coupon_to_redis(obj)
  43. else:
  44. redis.delete(f"{obj.user.id}:{obj.id}")
  45. def delete_model(self, request, obj):
  46. """删除记录时自动执行的钩子"""
  47. # 如果系统后台管理员删除当前优惠券记录,则redis中的对应记录也被删除
  48. print(obj, "详情页中删除一个记录")
  49. redis = get_redis_connection("coupon")
  50. redis.delete(f"{obj.user.id}:{obj.id}")
  51. obj.delete()
  52. def delete_queryset(self, request, queryset):
  53. """在列表页中进行删除优惠券记录时,也要同步删除容redis中的记录"""
  54. print(queryset, "列表页中删除多个记录")
  55. redis = get_redis_connection("coupon")
  56. for obj in queryset:
  57. redis.delete(f"{obj.user.id}:{obj.id}")
  58. queryset.delete()
  59. admin.site.register(CouponLog, CouponLogModelAdmin)

order/urls.py,代码:

  1. from django.urls import path,re_path
  2. from . import views
  3. urlpatterns = [
  4. path("", views.OrderCreateAPIView.as_view()),
  5. path("pay/choices/", views.OrderPayChoicesAPIView.as_view()),
  6. path("list/", views.OrderListAPIView.as_view()),
  7. re_path("^(?P<pk>\d+)/$", views.OrderViewSet.as_view({"put": "pay_cancel"})),
  8. ]

客户端实现取消订单功能

api/order.js,代码:

  1. import http from "../utils/http";
  2. import {reactive} from "vue";
  3. const order = reactive({
  4. // 中间代码省略....
  5. // 中间代码省略....
  6. order_cancel(order_id,token){
  7. // 取消订单操作
  8. return http.put(`/orders/${order_id}/`, {},{
  9. headers:{
  10. Authorization: "jwt " + token,
  11. }
  12. })
  13. }
  14. })
  15. export default order;

components/user/Order.vue,代码:

  1. <script setup>
  2. import {watch} from "vue";
  3. import order from "../../api/order"
  4. const getOrderStatus = ()=>{
  5. // 获取订单状态选项
  6. order.get_order_status().then(response=>{
  7. order.order_status_chioces = response.data;
  8. })
  9. }
  10. getOrderStatus()
  11. const getOrderList = ()=>{
  12. // 获取当前登录用户的订单列表
  13. let token = sessionStorage.token || localStorage.token;
  14. order.get_order_list(token).then(response=>{
  15. order.order_list = response.data.results
  16. order.count = response.data.count
  17. })
  18. }
  19. getOrderList()
  20. let pay_now = (order_info)=>{
  21. // 订单继续支付
  22. }
  23. let pay_cancel = (order_info)=>{
  24. // 取消订单
  25. let token = sessionStorage.token || localStorage.token;
  26. order.order_cancel(order_info.id,token).then(response=>{
  27. order_info.order_status = 2;
  28. })
  29. }
  30. let evaluate_now = (order_info)=>{
  31. // 订单评价
  32. }
  33. let order_refund = (order_info)=>{
  34. // 申请退款
  35. }
  36. let delete_order = (order_info)=>{
  37. // 删除订单
  38. }
  39. let recovery_now = (order)=>{
  40. // 恢复订单
  41. }
  42. // 切换页码
  43. let current_page = (page)=>{
  44. order.page = page;
  45. }
  46. // 切换分页数据量
  47. let current_size = (size)=>{
  48. order.size = size;
  49. }
  50. // 监听页码
  51. watch(
  52. ()=>order.page,
  53. ()=>{
  54. getOrderList()
  55. }
  56. )
  57. // 监听页面数据量大小
  58. watch(
  59. ()=>order.size,
  60. ()=>{
  61. order.page = 1;
  62. getOrderList()
  63. }
  64. )
  65. // 监听订单状态选项
  66. watch(
  67. ()=>order.order_status,
  68. ()=>{
  69. order.page = 1;
  70. getOrderList()
  71. }
  72. )
  73. </script>

再次支付

components/user/Order.vue,代码:

  1. <script setup>
  2. import {watch} from "vue";
  3. import order from "../../api/order"
  4. const getOrderStatus = ()=>{
  5. // 获取订单状态选项
  6. order.get_order_status().then(response=>{
  7. order.order_status_chioces = response.data;
  8. })
  9. }
  10. getOrderStatus()
  11. const getOrderList = ()=>{
  12. // 获取当前登录用户的订单列表
  13. let token = sessionStorage.token || localStorage.token;
  14. order.get_order_list(token).then(response=>{
  15. order.order_list = response.data.results
  16. order.count = response.data.count
  17. })
  18. }
  19. getOrderList()
  20. let pay_now = (order_info)=>{
  21. // 订单继续支付
  22. order.order_number = order_info.order_number;
  23. let token = sessionStorage.token || localStorage.token;
  24. if (order.pay_type === 0) {
  25. // 如果当前订单的支付方式属于支付宝,发起支付宝支付
  26. order.alipay_page_pay(order_info.order_number, token).then(response => {
  27. // 新开浏览器窗口,跳转到支付页面
  28. window.open(response.data.link, "_blank");
  29. // 新建定时器,每隔5秒到服务端查询一次当前订单的支付结果
  30. let max_query_timer = 180;
  31. clearInterval(order.timer);
  32. order.timer = setInterval(() => {
  33. max_query_timer--;
  34. if(max_query_timer > 0){
  35. order.query_order(token).then(response => {
  36. order_info.order_status = 1;
  37. clearInterval(order.timer);
  38. })
  39. }else{
  40. clearInterval(order.timer);
  41. }
  42. }, 5000);
  43. })
  44. }
  45. }
  46. let pay_cancel = (order_info)=>{
  47. // 取消订单
  48. let token = sessionStorage.token || localStorage.token;
  49. order.order_cancel(order_info.id,token).then(response=>{
  50. order_info.order_status = 2;
  51. })
  52. }
  53. let evaluate_now = (order_info)=>{
  54. // 订单评价
  55. }
  56. let order_refund = (order_info)=>{
  57. // 申请退款
  58. }
  59. let delete_order = (order_info)=>{
  60. // 删除订单
  61. }
  62. let recovery_now = (order)=>{
  63. // 恢复订单
  64. }
  65. // 切换页码
  66. let current_page = (page)=>{
  67. order.page = page;
  68. }
  69. // 切换分页数据量
  70. let current_size = (size)=>{
  71. order.size = size;
  72. }
  73. // 监听页码
  74. watch(
  75. ()=>order.page,
  76. ()=>{
  77. getOrderList()
  78. }
  79. )
  80. // 监听页面数据量大小
  81. watch(
  82. ()=>order.size,
  83. ()=>{
  84. order.page = 1;
  85. getOrderList()
  86. }
  87. )
  88. // 监听订单状态选项
  89. watch(
  90. ()=>order.order_status,
  91. ()=>{
  92. order.page = 1;
  93. getOrderList()
  94. }
  95. )
  96. </script>

提交代码版本

  1. cd /home/moluo/Desktop/luffycity
  2. git add .
  3. git commit -m "feature: 订单状态切换-取消订单与再次支付"
  4. git push

订单超时

用户下单在15分钟以后自动判断订单状态如果是0, 则直接改成3,恢复当前订单的优惠券和用户积分。

使用Celery的定时任务来完成订单超时功能
  1. 定时任务[async_tasks],主要是依靠操作系统的计划任务或者第三方软件的定时执行
  2. 定时任务的常见场景:
  3. 1. 订单超时取消
  4. 2. 生日邮件[例如,每天凌晨检查当天有没有用户生日,有则发送一份祝福邮件]
  5. 3. 财务统计[例如,每个月的1号,把当月的订单进行统计,生成一个财务记录,保存到数据库中]
  6. 4. 页面缓存[例如,把首页设置为每隔5分钟生成一次缓存]
  7. django中要实现订单的超时取消,有以下2种类型,4种方式:
  8. 1. 通过计划任务来实现定时多次
  9. 计划任务,是celery提供给开发者设置周期任务的,可以定时多次,例如:每周一次,每分钟一次
  10. 1.1 Celery本身提供了计划任务的schedules执行
  11. 1.2 安装并配置django的第三方模块django-crontab[依靠系统本身的计划任务来完成,与celery无关]
  12. 2. 通过定时任务来实现定时一次
  13. 2.1 celery提供的apply_async来完成
  14. 2.2 redis值空间值事件,实际上就是基于redis的发布订阅的特性来完成

在实现订单超时的定时任务之前,我们需要先简单使用一下定时任务。orders/tasks.py,代码:

  1. import logging
  2. from celery import shared_task
  3. logger = logging.getLogger("django")
  4. @shared_task(name="order_cancel")
  5. def order_cancel(order_id):
  6. print(order_id)
  7. return True

终端下重启celery。并进入django内置的终端进行异步定时任务的测试。

  1. # 第一个终端
  2. celery -A luffycityapi worker -l INFO
  3. # 第二个终端
  4. # 进入虚拟环境
  5. conda activate luffycity
  6. # 进入项目根目录
  7. cd ~/Desktop/luffycity/luffycityapi
  8. python manage.py shell
  9. from orders.tasks import order_timeout
  10. ret = order_timeout.apply_async(kwargs={"order_id": 3}, countdown=15) # countdown为定时时间,单位:秒

效果:

chapter7.4-订单处理 - 图4

在此之前,我们已经在文件utils/constants.py中,对定时任务的定时时间设置了一个常量

  1. # 订单超时的时间(单位:秒)
  2. ORDER_TIMEOUT = 15 * 60

在用户下单成功时,设置订单超时的定时任务。

orders/tasks.py,代码:

  1. from celery import shared_task
  2. from django.db import transaction
  3. from .models import Order
  4. from coupon.services import add_coupon_to_redis
  5. import logging
  6. logger = logging.getLogger("django")
  7. @shared_task(name="order_timeout")
  8. def order_timeout(order_id):
  9. print(f"要超时取消的订单ID={order_id}")
  10. try:
  11. order = Order.objects.get(pk=order_id)
  12. except Exception as e:
  13. logger.warning(f"订单不存在!order_id:{order_id}: {e}")
  14. return
  15. if order.order_status == 0:
  16. """只针对未支付的订单进行超时取消"""
  17. with transaction.atomic():
  18. save_id = transaction.savepoint()
  19. try:
  20. # 1. 查询当前订单是否使用了积分,如果有则恢复
  21. if order.credit > 0:
  22. order.user.credit += order.credit
  23. order.user.save()
  24. # 2. 查询当前订单是否使用了优惠券,如果有则恢复
  25. obj = order.to_coupon.first()
  26. if obj:
  27. add_coupon_to_redis(obj)
  28. # 3. 切换当前订单为取消状态
  29. order.order_status = 3
  30. order.save()
  31. return {"order_id": order.id, "status": True, "errmsg": f"订单超时取消成功!"}
  32. except Exception as e:
  33. transaction.savepoint_rollback(save_id)
  34. logger.warning(f"过期订单无法处理!order_id:{order_id}: {e}")
  35. return {"order_id": order.id, "status": False, "errmsg": f"{e}"}

在用户下单的时候,设置定时任务,orders/serializers.py的create创建订单时,代码:

  1. import logging
  2. import constants
  3. from datetime import datetime
  4. from rest_framework import serializers
  5. from django_redis import get_redis_connection
  6. from django.db import transaction
  7. from .models import Order, OrderDetail, Course
  8. from coupon.models import CouponLog
  9. from .tasks import order_timeout
  10. logger = logging.getLogger("django")
  11. class OrderModelSerializer(serializers.ModelSerializer):
  12. user_coupon_id = serializers.IntegerField(write_only=True, default=-1)
  13. order_timeout = serializers.IntegerField(read_only=True)
  14. class Meta:
  15. model = Order
  16. fields = ["pay_type", "id", "order_number", "user_coupon_id", "credit", "order_timeout"]
  17. read_only_fields = ["id", "order_number"]
  18. extra_kwargs = {
  19. "pay_type": {"write_only": True},
  20. "credit": {"write_only": True},
  21. }
  22. def create(self, validated_data):
  23. """创建订单"""
  24. redis = get_redis_connection("cart")
  25. user = self.context["request"].user
  26. user_id = user.id
  27. # 判断用户如果使用了优惠券,则优惠券需要判断验证
  28. user_coupon_id = validated_data.get("user_coupon_id")
  29. # 本次下单时,用户使用的优惠券
  30. user_coupon = None
  31. if user_coupon_id != -1:
  32. user_coupon = CouponLog.objects.filter(pk=user_coupon_id, user_id=user_id).first()
  33. # 本次下单时使用的积分数量
  34. use_credit = validated_data.get("credit", 0)
  35. if use_credit > 0 and use_credit > user.credit:
  36. raise serializers.ValidationError(detail="您拥有的积分不足以抵扣本次下单的积分,请重新下单!")
  37. # 开启事务操作,保证下单过程中的所有数据库的原子性
  38. with transaction.atomic():
  39. # 设置事务的回滚点标记
  40. t1 = transaction.savepoint()
  41. try:
  42. # 创建订单记录
  43. order = Order.objects.create(
  44. name="购买课程", # 订单标题
  45. user_id=user_id, # 当前下单的用户ID
  46. # order_number = datetime.now().strftime("%Y%m%d%H%M%S") + ("%08d" % user_id) + "%08d" % random.randint(1,99999999) # 基于随机数生成唯一订单号
  47. order_number=datetime.now().strftime("%Y%m%d") + ("%08d" % user_id) + "%08d" % redis.incr("order_number"), # 基于redis生成分布式唯一订单号
  48. pay_type=validated_data.get("pay_type"), # 支付方式
  49. )
  50. # 记录本次下单的商品列表
  51. cart_hash = redis.hgetall(f"cart_{user_id}")
  52. if len(cart_hash) < 1:
  53. raise serializers.ValidationError(detail="购物车没有要下单的商品")
  54. # 提取购物车中所有勾选状态为b'1'的商品
  55. course_id_list = [int(key.decode()) for key, value in cart_hash.items() if value == b'1']
  56. # 添加订单与课程的关系
  57. course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()
  58. detail_list = []
  59. total_price = 0 # 本次订单的总价格
  60. real_price = 0 # 本次订单的实付总价
  61. # 用户使用优惠券或积分以后,需要在服务端计算本次使用优惠券或积分的最大优惠额度
  62. total_discount_price = 0 # 总优惠价格
  63. max_discount_course = None # 享受最大优惠的课程
  64. # 本次下单最多可以抵扣的积分
  65. max_use_credit = 0
  66. for course in course_list:
  67. discount_price = course.discount.get("price", None) # 获取课程原价
  68. if discount_price is not None:
  69. discount_price = float(discount_price)
  70. discount_name = course.discount.get("type", "")
  71. detail_list.append(OrderDetail(
  72. order=order,
  73. course=course,
  74. name=course.name,
  75. price=course.price,
  76. real_price=course.price if discount_price is None else discount_price,
  77. discount_name=discount_name,
  78. ))
  79. # 统计订单的总价和实付总价
  80. total_price += float(course.price)
  81. real_price += float(course.price if discount_price is None else discount_price)
  82. # 在用户使用了优惠券,并且当前课程没有参与其他优惠活动时,找到最佳优惠课程
  83. if user_coupon and discount_price is None:
  84. if max_discount_course is None:
  85. max_discount_course = course
  86. else:
  87. if course.price >= max_discount_course.price:
  88. max_discount_course = course
  89. # 添加每个课程的可用积分
  90. if use_credit > 0 and course.credit > 0:
  91. max_use_credit += course.credit
  92. # 在用户使用了优惠券以后,根据循环中得到的最佳优惠课程进行计算最终抵扣金额
  93. if user_coupon:
  94. # 优惠公式
  95. sale = float(user_coupon.coupon.sale[1:])
  96. if user_coupon.coupon.discount == 1:
  97. """减免优惠券"""
  98. total_discount_price = sale
  99. elif user_coupon.coupon.discount == 2:
  100. """折扣优惠券"""
  101. total_discount_price = float(max_discount_course.price) * (1 - sale)
  102. if use_credit > 0:
  103. if max_use_credit < use_credit:
  104. raise serializers.ValidationError(detail="本次使用的抵扣积分数额超过了限制!")
  105. # 当前订单添加积分抵扣的数量
  106. order.credit = use_credit
  107. total_discount_price = float(use_credit / constants.CREDIT_TO_MONEY)
  108. # 扣除用户拥有的积分,后续在订单超时未支付,则返还订单中对应数量的积分给用户。如果订单成功支付,则添加一个积分流水记录。
  109. user.credit = user.credit - use_credit
  110. user.save()
  111. # 一次性批量添加本次下单的商品记录
  112. OrderDetail.objects.bulk_create(detail_list)
  113. # 保存订单的总价格和实付价格
  114. order.total_price = real_price
  115. order.real_price = float(real_price - total_discount_price)
  116. order.save()
  117. # 删除购物车中被勾选的商品,保留没有被勾选的商品信息
  118. cart = {key: value for key, value in cart_hash.items() if value == b'0'}
  119. pipe = redis.pipeline()
  120. pipe.multi()
  121. # 删除原来的购物车
  122. pipe.delete(f"cart_{user_id}")
  123. # 重新把未勾选的商品记录到购物车中
  124. if cart:
  125. pipe.hmset(f"cart_{user_id}", cart)
  126. pipe.execute()
  127. # 如果有使用了优惠券,则把优惠券和当前订单进行绑定
  128. if user_coupon:
  129. user_coupon.order = order
  130. user_coupon.save()
  131. # 把优惠券从redis中移除
  132. redis = get_redis_connection("coupon")
  133. redis.delete(f"{user_id}:{user_coupon_id}")
  134. # 将来订单状态发生改变,再修改优惠券的使用状态,如果订单过期,则再次还原优惠券到redis中
  135. order_timeout.apply_async(kwargs={"order_id": order.id}, countdown=constants.ORDER_TIMEOUT)
  136. order.order_timeout = constants.ORDER_TIMEOUT
  137. return order
  138. except Exception as e:
  139. # 1. 记录日志
  140. logger.error(f"订单创建失败:{e}")
  141. # 2. 事务回滚
  142. transaction.savepoint_rollback(t1)
  143. # 3. 抛出异常,通知视图返回错误提示
  144. raise serializers.ValidationError(detail="订单创建失败!")
  145. class OrderDetailMdoelSerializer(serializers.ModelSerializer):
  146. """订单详情序列化器"""
  147. # 通过source修改数据源,可以把需要调用的部分外键字段提取到当前序列化器中
  148. course_id = serializers.IntegerField(source="course.id")
  149. course_name = serializers.CharField(source="course.name")
  150. course_cover = serializers.ImageField(source="course.course_cover")
  151. class Meta:
  152. model = OrderDetail
  153. fields = ["id", "price", "real_price", "discount_name", "course_id", "course_name", "course_cover"]
  154. class OrderListModelSerializer(serializers.ModelSerializer):
  155. """订单列表序列化器"""
  156. order_courses = OrderDetailMdoelSerializer(many=True)
  157. class Meta:
  158. model = Order
  159. fields = ["id", "order_number", "total_price", "real_price", "pay_time", "created_time", "credit", "coupon",
  160. "pay_type", "order_status", "order_courses"]

接下来,我们就可以重启Celery即可。运行celery

cd /home/moluo/Desktop/luffycity/luffycityapi
celery -A luffycityapi worker -l INFO

提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 订单状态切换-订单超时处理"
git push

关于celery在运行过程中, 默认情况下是无法在关机以后自动重启的。所以我们一般开发中会使用supervisor进程监控来对celery程序进行运行监控!当celery没有启动的情况下,supervisor会自动启动celery,所以我们需要安装supervisor并且编写一个supervisor的控制脚本,在脚本中编写对celery进行启动的命令即可。

安装和启动celery任务监控器

针对celery中的任务执行过程,我们也可以安装一个flower的工具来进行监控。

pip install flower
cd /home/moluo/Desktop/luffycity/luffycityapi
# 保证celery在启动中
celery -A luffycityapi worker -l INFO
# 再启动celery-flower
celery -A luffycityapi flower --port=5555

supervisor启动celery&flower

Supervisor是用Python开发的一套通用的进程管理程序,能将一个普通的命令行进程变为系统守护进程daemon,并监控进程状态,异常退出时能自动重启。

pip install supervisor
# 注意:如果supervisor是安装在虚拟环境的,则每次使用supervisor务必在虚拟环境中进行后面所有的操作
# conda activate luffycity

supervisor配置文档:http://supervisord.org/configuration.html

对Supervisor初始化配置

# 在项目根目录下创建存储supervisor配置目录,在luffycityapi创建scripts目录,已经创建则忽略
conda activate luffycity
cd /home/moluo/Desktop/luffycity/luffycityapi
mkdir -p scripts && cd scripts
# 生成初始化supervisor核心配置文件,echo_supervisord_conf是supervisor安装成功以后,自动附带的。
echo_supervisord_conf > supervisord.conf
# 可以通过 ls 查看scripts下是否多了supervisord.conf这个文件,表示初始化配置生成了。
# 在编辑器中打开supervisord.conf,并去掉最后一行的注释分号。
# 修改如下,表示让supervisor自动加载当前supervisord.conf所在目录下所有ini配置文件

supervisord/conf.py,主要修改文件中的39, 40,75,76,169,170行去掉左边注释,其中170修改成当前目录。配置代码:

; Sample supervisor config file.
;
; For more information on the config file, please see:
; http://supervisord.org/configuration.html
;
; Notes:
;  - Shell expansion ("~" or "$HOME") is not supported.  Environment
;    variables can be expanded using this syntax: "%(ENV_HOME)s".
;  - Quotes around values are not supported, except in the case of
;    the environment= options as shown below.
;  - Comments must have a leading space: "a=b ;comment" not "a=b;comment".
;  - Command will be truncated if it looks like a config file comment, e.g.
;    "command=bash -c 'foo ; bar'" will truncate to "command=bash -c 'foo ".
;
; Warning:
;  Paths throughout this example file use /tmp because it is available on most
;  systems.  You will likely need to change these to locations more appropriate
;  for your system.  Some systems periodically delete older files in /tmp.
;  Notably, if the socket file defined in the [unix_http_server] section below
;  is deleted, supervisorctl will be unable to connect to supervisord.

[unix_http_server]
file=/tmp/supervisor.sock   ; the path to the socket file
;chmod=0700                 ; socket file mode (default 0700)
;chown=nobody:nogroup       ; socket file uid:gid owner
;username=user              ; default is no username (open server)
;password=123               ; default is no password (open server)

; Security Warning:
;  The inet HTTP server is not enabled by default.  The inet HTTP server is
;  enabled by uncommenting the [inet_http_server] section below.  The inet
;  HTTP server is intended for use within a trusted environment only.  It
;  should only be bound to localhost or only accessible from within an
;  isolated, trusted network.  The inet HTTP server does not support any
;  form of encryption.  The inet HTTP server does not use authentication
;  by default (see the username= and password= options to add authentication).
;  Never expose the inet HTTP server to the public internet.

[inet_http_server]         ; inet (TCP) server disabled by default
port=127.0.0.1:9001        ; ip_address:port specifier, *:port for all iface
;username=user              ; default is no username (open server)
;password=123               ; default is no password (open server)

[supervisord]
logfile=/tmp/supervisord.log ; main log file; default $CWD/supervisord.log
logfile_maxbytes=50MB        ; max main logfile bytes b4 rotation; default 50MB
logfile_backups=10           ; # of main logfile backups; 0 means none, default 10
loglevel=info                ; log level; default info; others: debug,warn,trace
pidfile=/tmp/supervisord.pid ; supervisord pidfile; default supervisord.pid
nodaemon=false               ; start in foreground if true; default false
silent=false                 ; no logs to stdout if true; default false
minfds=1024                  ; min. avail startup file descriptors; default 1024
minprocs=200                 ; min. avail process descriptors;default 200
;umask=022                   ; process file creation umask; default 022
;user=supervisord            ; setuid to this UNIX account at startup; recommended if root
;identifier=supervisor       ; supervisord identifier, default is 'supervisor'
;directory=/tmp              ; default is not to cd during start
;nocleanup=true              ; don't clean up tempfiles at start; default false
;childlogdir=/tmp            ; 'AUTO' child log dir, default $TEMP
;environment=KEY="value"     ; key value pairs to add to environment
;strip_ansi=false            ; strip ansi escape codes in logs; def. false

; The rpcinterface:supervisor section must remain in the config file for
; RPC (supervisorctl/web interface) to work.  Additional interfaces may be
; added by defining them in separate [rpcinterface:x] sections.

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

; The supervisorctl section configures how supervisorctl will connect to
; supervisord.  configure it match the settings in either the unix_http_server
; or inet_http_server section.

[supervisorctl]
; serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL  for a unix socket
serverurl=http://127.0.0.1:9001 ; use an http:// url to specify an inet socket
;username=chris              ; should be same as in [*_http_server] if set
;password=123                ; should be same as in [*_http_server] if set
;prompt=mysupervisor         ; cmd line prompt (default "supervisor")
;history_file=~/.sc_history  ; use readline history if available

; The sample program section below shows all possible program subsection values.
; Create one or more 'real' program: sections to be able to control them under
; supervisor.

;[program:theprogramname]
;command=/bin/cat              ; the program (relative uses PATH, can take args)
;process_name=%(program_name)s ; process_name expr (default %(program_name)s)
;numprocs=1                    ; number of processes copies to start (def 1)
;directory=/tmp                ; directory to cwd to before exec (def no cwd)
;umask=022                     ; umask for process (default None)
;priority=999                  ; the relative start priority (default 999)
;autostart=true                ; start at supervisord start (default: true)
;startsecs=1                   ; # of secs prog must stay up to be running (def. 1)
;startretries=3                ; max # of serial start failures when starting (default 3)
;autorestart=unexpected        ; when to restart if exited after running (def: unexpected)
;exitcodes=0                   ; 'expected' exit codes used with autorestart (default 0)
;stopsignal=QUIT               ; signal used to kill process (default TERM)
;stopwaitsecs=10               ; max num secs to wait b4 SIGKILL (default 10)
;stopasgroup=false             ; send stop signal to the UNIX process group (default false)
;killasgroup=false             ; SIGKILL the UNIX process group (def false)
;user=chrism                   ; setuid to this UNIX account to run the program
;redirect_stderr=true          ; redirect proc stderr to stdout (default false)
;stdout_logfile=/a/path        ; stdout log path, NONE for none; default AUTO
;stdout_logfile_maxbytes=1MB   ; max # logfile bytes b4 rotation (default 50MB)
;stdout_logfile_backups=10     ; # of stdout logfile backups (0 means none, default 10)
;stdout_capture_maxbytes=1MB   ; number of bytes in 'capturemode' (default 0)
;stdout_events_enabled=false   ; emit events on stdout writes (default false)
;stdout_syslog=false           ; send stdout to syslog with process name (default false)
;stderr_logfile=/a/path        ; stderr log path, NONE for none; default AUTO
;stderr_logfile_maxbytes=1MB   ; max # logfile bytes b4 rotation (default 50MB)
;stderr_logfile_backups=10     ; # of stderr logfile backups (0 means none, default 10)
;stderr_capture_maxbytes=1MB   ; number of bytes in 'capturemode' (default 0)
;stderr_events_enabled=false   ; emit events on stderr writes (default false)
;stderr_syslog=false           ; send stderr to syslog with process name (default false)
;environment=A="1",B="2"       ; process environment additions (def no adds)
;serverurl=AUTO                ; override serverurl computation (childutils)

; The sample eventlistener section below shows all possible eventlistener
; subsection values.  Create one or more 'real' eventlistener: sections to be
; able to handle event notifications sent by supervisord.

;[eventlistener:theeventlistenername]
;command=/bin/eventlistener    ; the program (relative uses PATH, can take args)
;process_name=%(program_name)s ; process_name expr (default %(program_name)s)
;numprocs=1                    ; number of processes copies to start (def 1)
;events=EVENT                  ; event notif. types to subscribe to (req'd)
;buffer_size=10                ; event buffer queue size (default 10)
;directory=/tmp                ; directory to cwd to before exec (def no cwd)
;umask=022                     ; umask for process (default None)
;priority=-1                   ; the relative start priority (default -1)
;autostart=true                ; start at supervisord start (default: true)
;startsecs=1                   ; # of secs prog must stay up to be running (def. 1)
;startretries=3                ; max # of serial start failures when starting (default 3)
;autorestart=unexpected        ; autorestart if exited after running (def: unexpected)
;exitcodes=0                   ; 'expected' exit codes used with autorestart (default 0)
;stopsignal=QUIT               ; signal used to kill process (default TERM)
;stopwaitsecs=10               ; max num secs to wait b4 SIGKILL (default 10)
;stopasgroup=false             ; send stop signal to the UNIX process group (default false)
;killasgroup=false             ; SIGKILL the UNIX process group (def false)
;user=chrism                   ; setuid to this UNIX account to run the program
;redirect_stderr=false         ; redirect_stderr=true is not allowed for eventlisteners
;stdout_logfile=/a/path        ; stdout log path, NONE for none; default AUTO
;stdout_logfile_maxbytes=1MB   ; max # logfile bytes b4 rotation (default 50MB)
;stdout_logfile_backups=10     ; # of stdout logfile backups (0 means none, default 10)
;stdout_events_enabled=false   ; emit events on stdout writes (default false)
;stdout_syslog=false           ; send stdout to syslog with process name (default false)
;stderr_logfile=/a/path        ; stderr log path, NONE for none; default AUTO
;stderr_logfile_maxbytes=1MB   ; max # logfile bytes b4 rotation (default 50MB)
;stderr_logfile_backups=10     ; # of stderr logfile backups (0 means none, default 10)
;stderr_events_enabled=false   ; emit events on stderr writes (default false)
;stderr_syslog=false           ; send stderr to syslog with process name (default false)
;environment=A="1",B="2"       ; process environment additions
;serverurl=AUTO                ; override serverurl computation (childutils)

; The sample group section below shows all possible group values.  Create one
; or more 'real' group: sections to create "heterogeneous" process groups.

;[group:thegroupname]
;programs=progname1,progname2  ; each refers to 'x' in [program:x] definitions
;priority=999                  ; the relative start priority (default 999)

; The [include] section can just contain the "files" setting.  This
; setting can list multiple files (separated by whitespace or
; newlines).  It can also contain wildcards.  The filenames are
; interpreted as relative to this file.  Included files *cannot*
; include files themselves.

[include]
files = *.ini

创建luffycity_celery_worker.ini文件,启动我们项目worker主进程

cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
touch luffycity_celery_worker.ini
[program:luffycity_celery_worker]
# 启动命令 conda env list
command=/home/moluo/anaconda3/envs/luffycity/bin/celery -A luffycityapi worker -l info -n worker1
# 项目根目录的绝对路径[manage.py所在目录路径],通过pwd查看
directory=/home/moluo/Desktop/luffycity/luffycityapi
# 项目虚拟环境
enviroment=PATH="/home/moluo/anaconda3/envs/luffycity/bin"
# 运行日志绝对路径
stdout_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.worker.info.log
# 错误日志绝对路径
stderr_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.worker.error.log
# 自动启动,开机自启
autostart=true
# 启动当前命令的用户名
user=moluo
# 重启
autorestart=true
# 进程启动后跑了几秒钟,才被认定为成功启动,默认1
startsecs=10
# 进程结束后60秒才被认定结束
stopwatisecs=60
# 优先级,值小的优先启动
priority=990

创建luffycity_celery_beat.ini文件,来触发我们的beat定时计划任务

cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
touch luffycity_celery_beat.ini
[program:luffycity_celery_beat]
# 启动命令 conda env list
command=/home/moluo/anaconda3/envs/luffycity/bin/celery -A luffycityapi  beat -l info
# 项目根目录的绝对路径,通过pwd查看
directory=/home/moluo/Desktop/luffycity/luffycityapi
# 项目虚拟环境
enviroment=PATH="/home/moluo/anaconda3/envs/luffycity/bin"
# 运行日志绝对路径
stdout_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.beat.info.log
# 错误日志绝对路径
stderr_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.beat.error.log
# 自动启动,开机自启
autostart=true
# 重启
autorestart=true

# 进程启动后跑了几秒钟,才被认定为成功启动,默认1
startsecs=10

# 进程结束后60秒才被认定结束
stopwatisecs=60

# 优先级,值小的优先启动
priority=998

创建luffycity_celery_flower.ini文件,来启动我们的celery监控管理工具

cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
touch luffycity_celery_flower.ini
[program:luffycity_celery_flower]
# 启动命令 conda env list
command=/home/moluo/anaconda3/envs/luffycity/bin/celery -A luffycityapi flower --port=5555
# 项目根目录的绝对路径,通过pwd查看
directory=/home/moluo/Desktop/luffycity/luffycityapi
# 项目虚拟环境
enviroment=PATH="/home/moluo/anaconda3/envs/luffycity/bin"
# 输出日志绝对路径
stdout_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.flower.info.log
# 错误日志绝对路径
stderr_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.flower.error.log
# 自动启动,开机自启
autostart=true
# 重启
autorestart=true

# 进程启动后跑了几秒钟,才被认定为成功启动,默认1
startsecs=10

# 进程结束后60秒才被认定结束
stopwatisecs=60

# 优先级
priority=999

启动supervisor,确保此时你在项目路径下

cd ~/Desktop/luffycity/luffycityapi
supervisord -c scripts/supervisord.conf

通过浏览器访问http://127.0.0.1:9001

常用操作

命令 描述
supervisorctl stop program 停止某一个进程,program 就是进程名称,例如在ini文件首行定义的[program:进程名称]
supervisorctl stop all 停止全部进程
supervisorctl start program 启动某个进程,program同上,也支持启动所有的进程
supervisorctl restart program 重启某个进程,program同上,也支持重启所有的进程
supervisorctl reload 载入最新的配置文件,停止原有进程并按新的配置启动、管理所有进程
注意:start、restart、stop 等都不会载入最新的配置文件
supervisorctl update 根据最新的配置文件,启动新配置或有改动的进程,配置没有改动的进程不会受影响而重启
ps aux | grep supervisord 查看supervisor是否启动

把supervisor注册到ubuntu系统服务中并设置开机自启

cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
touch supervisor.service

supervisor.service,配置内容,并保存。需要通过conda env list 查看当前的虚拟环境路径

[Unit]
Description=supervisor
After=network.target

[Service]
Type=forking
ExecStart=/home/moluo/anaconda3/envs/luffycity/bin/supervisord -n -c /home/moluo/Desktop/luffycity/luffycityapi/scripts/supervisord.conf
ExecStop=/home/moluo/anaconda3/envs/luffycity/bin/supervisorctl $OPTIONS shutdown
ExecReload=/home/moluo/anaconda3/envs/luffycity/bin/supervisorctl $OPTIONS reload
KillMode=process
Restart=on-failure
RestartSec=42s

[Install]
WantedBy=multi-user.target

设置开机自启

# 创建日志文件
sudo chmod 766 /tmp/supervisord.log
cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
# 赋予权限
chmod 766 supervisor.service
# 复制到系统开启服务目录下
sudo cp supervisor.service /lib/systemd/system/
# 设置允许开机自启
systemctl enable supervisor.service
# 判断是否已经设置为开机自启了
systemctl is-enabled  supervisor.service
# 通过systemctl查看supervisor运行状态
systemctl status supervisor.service
# 如果查看服务状态时无法启动,则可以通过重启linux系统来测试是否因为前面的终端已经运行了supervisor导致的。当然,也可以手动关闭supervisor以及相关的服务。
# supervisorctl stop all
# ps aux | grep supervisord
# kill -9 51564  # 注意: 9068是举例的,具体看上一行的查询结果

提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 使用supervisor启动并管理celery相关进程"
git push