index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684
  1. <template>
  2. <view class="cart-container">
  3. <!-- 添加动画小球 -->
  4. <view class="cart-ball" :class="{ active: ballShow }" :style="ballStyle" v-show="ballShow">
  5. <view class="inner"></view>
  6. </view>
  7. <!-- 顶部导航栏 -->
  8. <view class="cart-header">
  9. <text class="header-title"></text>
  10. <text class="manage-btn" @tap="toggleManageMode">{{isManageMode ? '完成' : '管理'}}</text>
  11. </view>
  12. <!-- 购物车为空时显示 -->
  13. <view class="empty-cart" v-if="list.length === 0">
  14. <image src="/static/empty-cart.png" mode="aspectFit"></image>
  15. <view class="flex-items-plus">
  16. <image src="/static/images/empty.png" class="empty"></image>
  17. </view>
  18. <view class="empty-text">购物车是空的</view>
  19. <button class="go-shopping-btn" @tap="goShopping">去逛逛</button>
  20. </view>
  21. <!-- 购物车有商品时显示 -->
  22. <view class="cart-content" v-else>
  23. <!-- 商品列表 -->
  24. <view class="cart-list">
  25. <view class="cart-item" :class="{ 'out-of-stock': !item.stock || item.stock <= 0 }" v-for="(item, index) in list" :key="index">
  26. <checkbox :checked="item.selected" @tap="toggleSelect(index)" :color="'#ff6b81'"></checkbox>
  27. <view class="item-content" @tap="goToProductDetail(item)">
  28. <image :src="item.images" mode="aspectFill"></image>
  29. <view class="item-info">
  30. <view class="title">{{item.productName}}</view>
  31. <view class="sku-name">
  32. <text>
  33. 规格:
  34. </text>
  35. <text>
  36. {{item.skuName}}
  37. </text>
  38. </view>
  39. <view class="stock-info">
  40. <text class="stock-text">库存:{{item.stock || 0}}件</text>
  41. <text class="stock-warning" v-if="!item.stock || item.stock <= 0">(库存不足)</text>
  42. </view>
  43. <view class="price-quantity">
  44. <text class="price">¥{{item.price}}</text>
  45. <view class="quantity-control" v-if="!isManageMode" @tap.stop="">
  46. <text class="minus" @tap="decreaseQuantity(index)">-</text>
  47. <input type="number" v-model="item.count" @blur="updateQuantity(index)" />
  48. <text class="plus"
  49. :class="{ 'disabled': !item.stock || item.stock <= 0 || item.count >= item.stock }"
  50. @tap="increaseQuantity(index)">+</text>
  51. </view>
  52. </view>
  53. </view>
  54. </view>
  55. <view class="delete-icon" @tap="deleteItem(index)" v-if="!isManageMode">
  56. <uni-icons type="close" size="28"></uni-icons>
  57. </view>
  58. </view>
  59. </view>
  60. <!-- 底部结算栏 -->
  61. <view class="cart-footer">
  62. <view class="select-all">
  63. <checkbox :checked="isAllSelected" @tap="toggleSelectAll" :color="'#ff6b81'"></checkbox>
  64. <text>全选</text>
  65. </view>
  66. <view class="right-section" v-if="!isManageMode">
  67. <view class="total-info">
  68. <text>合计:</text>
  69. <text class="total-price">¥{{totalPrice}}</text>
  70. </view>
  71. <button class="checkout-btn" @tap="checkout">结算({{selectedCount}})</button>
  72. </view>
  73. <view class="right-section" v-else>
  74. <view class="total-info">
  75. <text>已选:</text>
  76. <text class="selected-count">{{selectedCount}}件</text>
  77. </view>
  78. <button class="delete-btn delete-btn-outline" @tap="batchDelete">删除</button>
  79. </view>
  80. </view>
  81. </view>
  82. </view>
  83. </template>
  84. <script>
  85. import {
  86. productCartList,
  87. productCartCount,
  88. productCartSave,
  89. productCartRemoveIds,
  90. saveCartOrder,
  91. productCartTotalPrice
  92. } from '../../../config/api.js';
  93. import {
  94. mapGetters
  95. } from 'vuex';
  96. export default {
  97. computed: {
  98. ...mapGetters(['isLogin'])
  99. },
  100. data() {
  101. return {
  102. list: [], // 购物车商品列表
  103. isLoading: false, // 是否正在加载
  104. noMoreData: false, // 是否没有更多数据
  105. isManageMode: false, // 是否为管理模式
  106. params: {
  107. current: 1,
  108. size: 10
  109. },
  110. totalPrice: '0.00', // 购物车总价
  111. // 添加小球动画相关数据
  112. ballShow: false,
  113. ballStyle: {
  114. left: '0px',
  115. top: '0px'
  116. }
  117. }
  118. },
  119. computed: {
  120. // 是否全选
  121. isAllSelected() {
  122. return this.list.length > 0 && this.list.every(item => item.selected);
  123. },
  124. // 已选商品数量
  125. selectedCount() {
  126. return this.list.filter(item => item.selected).length;
  127. },
  128. // 计算当前总价
  129. currentTotalPrice() {
  130. return this.list.reduce((total, item) => {
  131. if (item.selected) {
  132. return total + (Number(item.price) * Number(item.count));
  133. }
  134. return total;
  135. }, 0).toFixed(2);
  136. }
  137. },
  138. onShow() {
  139. // // 页面显示时获取购物车数据
  140. // if (this.isLogin) {
  141. this.getList()
  142. // } else {
  143. // this.$Router.push({
  144. // path: '/pages/user/login'
  145. // })
  146. // }
  147. },
  148. onReachBottom() {
  149. if (!this.isLoading && !this.noMoreData) {
  150. this.getList();
  151. }
  152. },
  153. methods: {
  154. // 获取购物车列表
  155. async getList() {
  156. if (this.isLoading) return;
  157. this.isLoading = true;
  158. try {
  159. const res = await productCartList(this.params);
  160. if (res.code === 200) {
  161. const records = res.data.records || [];
  162. records.forEach(item => {
  163. item.selected = false; // 添加选中状态属性
  164. });
  165. if (this.params.current === 1) {
  166. this.list = records;
  167. } else {
  168. this.list = [...this.list, ...records];
  169. }
  170. this.noMoreData = records.length < this.params.size;
  171. this.params.current++;
  172. // 获取购物车总价
  173. this.getTotalPrice();
  174. }
  175. } catch (error) {
  176. console.error('获取购物车列表失败:', error);
  177. uni.$u.toast('获取购物车列表失败');
  178. } finally {
  179. this.isLoading = false;
  180. }
  181. },
  182. // 获取购物车总价
  183. async getTotalPrice() {
  184. try {
  185. // 由于我们现在基于选中状态计算价格,直接使用计算属性
  186. this.totalPrice = this.currentTotalPrice;
  187. } catch (error) {
  188. console.error('获取购物车总价失败:', error);
  189. }
  190. },
  191. // Toggle manage mode
  192. toggleManageMode() {
  193. this.isManageMode = !this.isManageMode;
  194. // Reset selection when exiting manage mode
  195. if (!this.isManageMode) {
  196. this.list.forEach(item => item.selected = false);
  197. }
  198. },
  199. // Batch delete selected items
  200. async batchDelete() {
  201. const selectedItems = this.list.filter(item => item.selected);
  202. if (selectedItems.length === 0) {
  203. uni.showToast({
  204. title: '请选择要删除的商品',
  205. icon: 'none'
  206. });
  207. return;
  208. }
  209. uni.showModal({
  210. title: '提示',
  211. content: '确定要删除选中的商品吗?',
  212. success: async (res) => {
  213. if (res.confirm) {
  214. try {
  215. const ids = selectedItems.map(item => item.id).join(',');
  216. const res = await productCartRemoveIds(ids);
  217. if (res.code === 200) {
  218. // Remove deleted items from list
  219. this.list = this.list.filter(item => !item.selected);
  220. // 更新前端总价
  221. this.totalPrice = this.currentTotalPrice;
  222. uni.$u.toast('删除成功');
  223. // Exit manage mode if no items left
  224. if (this.list.length === 0) {
  225. this.isManageMode = false;
  226. }
  227. }
  228. } catch (error) {
  229. console.error('批量删除失败:', error);
  230. uni.$u.toast('删除失败');
  231. }
  232. }
  233. }
  234. });
  235. },
  236. // Toggle select item
  237. toggleSelect(index) {
  238. this.list[index].selected = !this.list[index].selected;
  239. this.totalPrice = this.currentTotalPrice;
  240. },
  241. // Toggle select all
  242. toggleSelectAll() {
  243. const newStatus = !this.isAllSelected;
  244. this.list.forEach(item => {
  245. item.selected = newStatus;
  246. });
  247. this.totalPrice = this.currentTotalPrice;
  248. },
  249. // Delete single item
  250. async deleteItem(index) {
  251. uni.showModal({
  252. title: '提示',
  253. content: '确定要删除这个商品吗?',
  254. success: async (res) => {
  255. if (res.confirm) {
  256. try {
  257. const response = await productCartRemoveIds(this.list[index].id);
  258. if (response.code === 200) {
  259. // Remove item from list
  260. this.list.splice(index, 1);
  261. // 更新前端总价
  262. this.totalPrice = this.currentTotalPrice;
  263. uni.$u.toast('删除成功');
  264. }
  265. } catch (error) {
  266. console.error('删除失败:', error);
  267. uni.$u.toast('删除失败');
  268. }
  269. }
  270. }
  271. });
  272. },
  273. // Increase quantity
  274. async increaseQuantity(index) {
  275. const item = this.list[index];
  276. const newCount = Number(item.count) + 1;
  277. // 检查库存
  278. if (!item.stock || item.stock <= 0) {
  279. uni.$u.toast('该商品库存不足');
  280. return;
  281. }
  282. if (newCount > item.stock) {
  283. uni.$u.toast(`该商品库存仅剩${item.stock}件`);
  284. return;
  285. }
  286. await this.updateItemQuantity(index, newCount);
  287. },
  288. // Decrease quantity
  289. async decreaseQuantity(index) {
  290. const item = this.list[index];
  291. if (Number(item.count) > 1) {
  292. const newCount = Number(item.count) - 1;
  293. await this.updateItemQuantity(index, newCount);
  294. }
  295. },
  296. // Update quantity
  297. async updateQuantity(index) {
  298. const item = this.list[index];
  299. const newCount = Number(item.count);
  300. if (newCount < 1) {
  301. item.count = 1;
  302. return;
  303. }
  304. // 检查库存
  305. if (!item.stock || item.stock <= 0) {
  306. uni.$u.toast('该商品库存不足');
  307. item.count = 0;
  308. return;
  309. }
  310. if (newCount > item.stock) {
  311. uni.$u.toast(`该商品库存仅剩${item.stock}件,已调整为最大可购买数量`);
  312. item.count = item.stock;
  313. await this.updateItemQuantity(index, item.stock);
  314. return;
  315. }
  316. await this.updateItemQuantity(index, newCount);
  317. },
  318. // Update item quantity in backend
  319. async updateItemQuantity(index, newCount) {
  320. const item = this.list[index];
  321. try {
  322. const res = await productCartSave({
  323. id: item.id,
  324. count: newCount
  325. });
  326. if (res.code === 200) {
  327. // Update item count
  328. item.count = newCount;
  329. // Update total price
  330. this.getTotalPrice();
  331. }
  332. } catch (error) {
  333. console.error('更新数量失败:', error);
  334. uni.$u.toast('更新数量失败');
  335. }
  336. },
  337. // Go shopping
  338. goShopping() {
  339. uni.switchTab({
  340. url: '/pages/shop/product-type-list'
  341. })
  342. },
  343. // 跳转到商品详情页
  344. goToProductDetail(item) {
  345. if (item.productId) {
  346. uni.navigateTo({
  347. url: `/packageShop/pages/detail/index?id=${item.productId}`
  348. });
  349. }
  350. },
  351. // Checkout
  352. checkout() {
  353. const selectedItems = this.list.filter(item => item.selected);
  354. if (selectedItems.length === 0) {
  355. uni.showToast({
  356. title: '请选择要结算的商品',
  357. icon: 'none'
  358. });
  359. return;
  360. }
  361. // 检查选中商品的库存
  362. for (let item of selectedItems) {
  363. if (!item.stock || item.stock <= 0) {
  364. uni.showToast({
  365. title: `商品"${item.productName}"库存不足,请先删除或更换`,
  366. icon: 'none',
  367. duration: 3000
  368. });
  369. return;
  370. }
  371. if (Number(item.count) > item.stock) {
  372. uni.showToast({
  373. title: `商品"${item.productName}"数量超出库存,库存仅剩${item.stock}件`,
  374. icon: 'none',
  375. duration: 3000
  376. });
  377. return;
  378. }
  379. }
  380. // 跳转到结算页面
  381. uni.navigateTo({
  382. url: `/packageShop/pages/settle/index?items=${encodeURIComponent(JSON.stringify(selectedItems))}`
  383. });
  384. }
  385. }
  386. }
  387. </script>
  388. <style>
  389. .cart-container {
  390. min-height: 100vh;
  391. background-color: #f8f8f8;
  392. padding-bottom: 120rpx;
  393. }
  394. .cart-header {
  395. display: flex;
  396. justify-content: space-between;
  397. align-items: center;
  398. padding: 20rpx 30rpx;
  399. background-color: #fff;
  400. }
  401. .header-title {
  402. font-size: 32rpx;
  403. font-weight: bold;
  404. }
  405. .manage-btn {
  406. font-size: 28rpx;
  407. color: #666;
  408. }
  409. .empty-cart {
  410. display: flex;
  411. flex-direction: column;
  412. align-items: center;
  413. padding-top: 200rpx;
  414. }
  415. .empty-cart image {
  416. width: 200rpx;
  417. height: 200rpx;
  418. margin-bottom: 30rpx;
  419. }
  420. .empty-text {
  421. font-size: 28rpx;
  422. color: #999;
  423. margin-bottom: 40rpx;
  424. }
  425. .go-shopping-btn {
  426. width: 240rpx;
  427. height: 80rpx;
  428. line-height: 80rpx;
  429. text-align: center;
  430. background-color: #F95B5B;
  431. color: #fff;
  432. border-radius: 40rpx;
  433. font-size: 28rpx;
  434. }
  435. .cart-list {
  436. padding: 20rpx;
  437. }
  438. .cart-item {
  439. display: flex;
  440. align-items: center;
  441. background-color: #fff;
  442. padding: 20rpx;
  443. margin-bottom: 20rpx;
  444. border-radius: 12rpx;
  445. }
  446. .cart-item.out-of-stock {
  447. background-color: #f9f9f9;
  448. opacity: 0.8;
  449. }
  450. .cart-item.out-of-stock .item-content image {
  451. opacity: 0.6;
  452. }
  453. .item-content {
  454. display: flex;
  455. flex: 1;
  456. margin-left: 20rpx;
  457. cursor: pointer;
  458. }
  459. .item-content:active {
  460. opacity: 0.8;
  461. }
  462. .item-content image {
  463. width: 160rpx;
  464. height: 160rpx;
  465. border-radius: 8rpx;
  466. }
  467. .item-info {
  468. flex: 1;
  469. margin-left: 20rpx;
  470. }
  471. .title {
  472. font-size: 28rpx;
  473. color: #333;
  474. margin-bottom: 10rpx;
  475. }
  476. .sku-name {
  477. font-size: 24rpx;
  478. color: #999;
  479. margin-bottom: 10rpx;
  480. }
  481. .stock-info {
  482. font-size: 24rpx;
  483. margin-bottom: 20rpx;
  484. }
  485. .stock-text {
  486. color: #666;
  487. }
  488. .stock-warning {
  489. color: #ff6b81;
  490. font-weight: bold;
  491. }
  492. .price-quantity {
  493. display: flex;
  494. justify-content: space-between;
  495. align-items: center;
  496. }
  497. .price {
  498. font-size: 32rpx;
  499. color: #ff6b81;
  500. font-weight: bold;
  501. }
  502. .quantity-control {
  503. display: flex;
  504. align-items: center;
  505. border: 1rpx solid #eee;
  506. border-radius: 6rpx;
  507. }
  508. .minus,
  509. .plus {
  510. width: 60rpx;
  511. height: 60rpx;
  512. line-height: 60rpx;
  513. text-align: center;
  514. font-size: 36rpx;
  515. color: #666;
  516. }
  517. .plus.disabled {
  518. color: #ccc;
  519. background-color: #f5f5f5;
  520. }
  521. .quantity-control input {
  522. width: 80rpx;
  523. height: 60rpx;
  524. text-align: center;
  525. font-size: 28rpx;
  526. border-left: 1rpx solid #eee;
  527. border-right: 1rpx solid #eee;
  528. }
  529. .delete-icon {
  530. padding: 20rpx;
  531. }
  532. .cart-footer {
  533. position: fixed;
  534. bottom: 0;
  535. left: 0;
  536. right: 0;
  537. height: 100rpx;
  538. background-color: #fff;
  539. display: flex;
  540. align-items: center;
  541. padding: 0 30rpx;
  542. box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
  543. }
  544. .select-all {
  545. display: flex;
  546. align-items: center;
  547. }
  548. .select-all text {
  549. font-size: 28rpx;
  550. color: #333;
  551. margin-left: 10rpx;
  552. }
  553. .right-section {
  554. flex: 1;
  555. display: flex;
  556. justify-content: flex-end;
  557. align-items: center;
  558. }
  559. .total-info {
  560. margin-right: 30rpx;
  561. }
  562. .total-price {
  563. font-size: 36rpx;
  564. color: #ff6b81;
  565. font-weight: bold;
  566. }
  567. .checkout-btn {
  568. width: 200rpx;
  569. height: 80rpx;
  570. line-height: 80rpx;
  571. text-align: center;
  572. background-color: #F95B5B;
  573. color: #fff;
  574. border-radius: 40rpx;
  575. font-size: 28rpx;
  576. }
  577. .delete-btn {
  578. width: 200rpx;
  579. height: 80rpx;
  580. line-height: 80rpx;
  581. text-align: center;
  582. background-color: #ff6b81;
  583. color: #fff;
  584. border-radius: 40rpx;
  585. font-size: 28rpx;
  586. }
  587. .delete-btn-outline {
  588. background-color: #fff;
  589. color: #ff6b81;
  590. border: 1rpx solid #ff6b81;
  591. }
  592. /* 购物车动画小球样式 */
  593. .cart-ball {
  594. position: fixed;
  595. left: -18px;
  596. top: 0;
  597. z-index: 999;
  598. transition: all 0.4s cubic-bezier(0.49, -0.29, 0.75, 0.41);
  599. opacity: 0;
  600. }
  601. .cart-ball.active {
  602. opacity: 1;
  603. }
  604. .cart-ball .inner {
  605. width: 16px;
  606. height: 16px;
  607. border-radius: 50%;
  608. background-color: #ff6b81;
  609. transition: all 0.4s linear;
  610. }
  611. </style>