巴青农资商城

index.vue 9.7KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. <template>
  2. <view class="cart-page">
  3. <view v-if="!loggedIn" class="cart-guest">
  4. <u-empty mode="car" text="登录后查看购物车" icon-size="100" />
  5. <button class="cart-btn-primary" @click="goLogin">去登录</button>
  6. </view>
  7. <template v-else>
  8. <view v-if="loading && !groups.length" class="cart-loading">
  9. <u-loading-icon mode="circle" />
  10. </view>
  11. <view v-else-if="loadFailed && !groups.length" class="cart-error">
  12. <u-empty mode="list" text="加载失败" icon-size="80" />
  13. <text class="cart-retry" @click="loadCart">点击重试</text>
  14. </view>
  15. <view v-else-if="!groups.length" class="cart-empty">
  16. <u-empty mode="car" :text="emptyText" icon-size="100" />
  17. <button class="cart-btn-primary" @click="goHome">去逛逛</button>
  18. </view>
  19. <template v-else>
  20. <view v-if="showInvalidBar" class="cart-toolbar">
  21. <text class="cart-toolbar__btn" @click="onCleanInvalid">清理失效商品</text>
  22. <text v-if="hasCheckedAny" class="cart-toolbar__btn" @click="onDeleteChecked">删除选中</text>
  23. </view>
  24. <scroll-view class="cart-scroll" scroll-y :style="{ height: scrollHeight }">
  25. <cart-shop-group
  26. v-for="group in groups"
  27. :key="group.shopId"
  28. :group="group"
  29. @shop-check="onShopCheck"
  30. @shop-click="onShopClick"
  31. @check-change="onItemCheck"
  32. @quantity-change="onQuantityChange"
  33. @delete="onDeleteItem"
  34. @goods-click="onGoodsClick"
  35. />
  36. </scroll-view>
  37. <view class="cart-footer">
  38. <view class="cart-footer__check" @click="onToggleSelectAll">
  39. <view class="check-icon" :class="{ 'check-icon--on': selectAllChecked }">
  40. <u-icon v-if="selectAllChecked" name="checkbox-mark" color="#fff" size="14" />
  41. </view>
  42. <text class="cart-footer__label">全选</text>
  43. </view>
  44. <view class="cart-footer__sum">
  45. <text class="cart-footer__sum-label">合计:</text>
  46. <text class="cart-footer__sum-price">¥{{ checkedSummary.checkedAmountText }}</text>
  47. </view>
  48. <button
  49. class="cart-footer__btn"
  50. :disabled="!checkedSummary.checkedCount"
  51. @click="onCheckout"
  52. >
  53. 去结算({{ checkedSummary.checkedCount }})
  54. </button>
  55. </view>
  56. </template>
  57. </template>
  58. </view>
  59. </template>
  60. <script setup>
  61. import { ref, computed } from 'vue'
  62. import { onShow } from '@dcloudio/uni-app'
  63. import { getToken } from '@/utils/auth'
  64. import {
  65. getCartList,
  66. updateCartChecked,
  67. updateCartQuantity,
  68. removeCartItem,
  69. removeCartItems,
  70. cleanInvalidCart,
  71. prepareCartCheckout
  72. } from '@/api/cart'
  73. import {
  74. mapCartList,
  75. hasInvalidCartItems,
  76. getCheckedShopIds,
  77. getCheckedPurchasableIds,
  78. getAllPurchasableItems
  79. } from '@/utils/cartDisplay'
  80. import { goCartCheckout } from '@/utils/checkoutNav'
  81. import { CART_EMPTY_TEXT, CART_MSG_CROSS_SHOP } from '@/constants/cart'
  82. import { goGoodsDetail } from '@/utils/goodsDetail'
  83. import { goShopHome } from '@/utils/shopNav'
  84. import { PAGE_LOGIN, PAGE_HOME } from '@/utils/pageRoute'
  85. import CartShopGroup from '@/components/cart/CartShopGroup.vue'
  86. const loggedIn = ref(false)
  87. const loading = ref(false)
  88. const loadFailed = ref(false)
  89. const groups = ref([])
  90. const checkedSummary = ref({
  91. checkedCount: 0,
  92. checkedQuantity: 0,
  93. checkedAmountText: '0'
  94. })
  95. const scrollHeight = ref('500px')
  96. const emptyText = CART_EMPTY_TEXT
  97. const actionLock = ref(false)
  98. const showInvalidBar = computed(() => hasInvalidCartItems(groups.value))
  99. const allPurchasable = computed(() => getAllPurchasableItems(groups.value))
  100. const selectAllChecked = computed(() => {
  101. const list = allPurchasable.value
  102. return list.length > 0 && list.every((item) => item.checked)
  103. })
  104. const hasCheckedAny = computed(() =>
  105. groups.value.some((g) => (g.items || []).some((item) => item.checked))
  106. )
  107. function calcScrollHeight() {
  108. try {
  109. const sys = uni.getSystemInfoSync()
  110. const h = sys.windowHeight || 600
  111. scrollHeight.value = `${h - 120}px`
  112. } catch (e) {
  113. scrollHeight.value = '500px'
  114. }
  115. }
  116. function applyCartData(data) {
  117. const mapped = mapCartList(data)
  118. groups.value = mapped.groups
  119. checkedSummary.value = mapped.checkedSummary
  120. }
  121. async function loadCart() {
  122. loading.value = true
  123. loadFailed.value = false
  124. try {
  125. const res = await getCartList()
  126. applyCartData(res.data)
  127. } catch (e) {
  128. loadFailed.value = true
  129. if (!groups.value.length) {
  130. groups.value = []
  131. }
  132. } finally {
  133. loading.value = false
  134. }
  135. }
  136. function buildCheckedPayload(items) {
  137. return items.map((item) => ({
  138. cartItemId: item.cartItemId,
  139. checked: !!item.checked
  140. }))
  141. }
  142. async function syncChecked(items) {
  143. if (!items.length || actionLock.value) return
  144. actionLock.value = true
  145. try {
  146. await updateCartChecked(buildCheckedPayload(items))
  147. await loadCart()
  148. } catch (e) {
  149. await loadCart()
  150. } finally {
  151. actionLock.value = false
  152. }
  153. }
  154. function onItemCheck(item, checked) {
  155. syncChecked([{ ...item, checked }])
  156. }
  157. function onShopCheck(group, checked) {
  158. const items = (group.items || [])
  159. .filter((row) => row.purchasable)
  160. .map((row) => ({ ...row, checked }))
  161. if (!items.length) return
  162. syncChecked(items)
  163. }
  164. function onToggleSelectAll() {
  165. const next = !selectAllChecked.value
  166. const items = allPurchasable.value.map((row) => ({ ...row, checked: next }))
  167. if (!items.length) return
  168. syncChecked(items)
  169. }
  170. async function onQuantityChange(item, quantity) {
  171. if (actionLock.value) return
  172. actionLock.value = true
  173. try {
  174. await updateCartQuantity(item.cartItemId, quantity)
  175. await loadCart()
  176. } catch (e) {
  177. await loadCart()
  178. } finally {
  179. actionLock.value = false
  180. }
  181. }
  182. function confirmRemove(onConfirm) {
  183. uni.showModal({
  184. title: '提示',
  185. content: '确定移出该商品吗?',
  186. success: (res) => {
  187. if (res.confirm) onConfirm()
  188. }
  189. })
  190. }
  191. async function onDeleteItem(item) {
  192. confirmRemove(async () => {
  193. try {
  194. await removeCartItem(item.cartItemId)
  195. uni.showToast({ title: '已移出', icon: 'none' })
  196. await loadCart()
  197. } catch (e) {
  198. // request 已提示
  199. }
  200. })
  201. }
  202. function onDeleteChecked() {
  203. const ids = []
  204. groups.value.forEach((g) => {
  205. ;(g.items || []).forEach((item) => {
  206. if (item.checked) ids.push(item.cartItemId)
  207. })
  208. })
  209. if (!ids.length) {
  210. uni.showToast({ title: '请先勾选商品', icon: 'none' })
  211. return
  212. }
  213. uni.showModal({
  214. title: '提示',
  215. content: `确定移出选中的 ${ids.length} 件商品吗?`,
  216. success: async (res) => {
  217. if (!res.confirm) return
  218. try {
  219. await removeCartItems(ids)
  220. uni.showToast({ title: '已移出', icon: 'none' })
  221. await loadCart()
  222. } catch (e) {
  223. // request 已提示
  224. }
  225. }
  226. })
  227. }
  228. async function onCleanInvalid() {
  229. uni.showModal({
  230. title: '提示',
  231. content: '确定清理全部失效商品吗?',
  232. success: async (res) => {
  233. if (!res.confirm) return
  234. try {
  235. const result = await cleanInvalidCart()
  236. const n = (result.data && result.data.removedCount) || 0
  237. uni.showToast({ title: n ? `已清理${n}件` : '暂无失效商品', icon: 'none' })
  238. await loadCart()
  239. } catch (e) {
  240. // request 已提示
  241. }
  242. }
  243. })
  244. }
  245. async function onCheckout() {
  246. const ids = getCheckedPurchasableIds(groups.value)
  247. if (!ids.length) {
  248. uni.showToast({ title: '请先勾选要结算的商品', icon: 'none' })
  249. return
  250. }
  251. const shopIds = getCheckedShopIds(groups.value)
  252. if (shopIds.size > 1) {
  253. uni.showToast({ title: CART_MSG_CROSS_SHOP, icon: 'none' })
  254. return
  255. }
  256. try {
  257. await prepareCartCheckout(ids)
  258. goCartCheckout(ids)
  259. } catch (e) {
  260. // request 已提示
  261. }
  262. }
  263. function onShopClick(group) {
  264. if (group && group.shopId) {
  265. goShopHome(group.shopId)
  266. }
  267. }
  268. function onGoodsClick(item) {
  269. if (item && item.goodsId) {
  270. goGoodsDetail(item.goodsId)
  271. }
  272. }
  273. function goLogin() {
  274. uni.navigateTo({ url: PAGE_LOGIN })
  275. }
  276. function goHome() {
  277. uni.switchTab({ url: PAGE_HOME })
  278. }
  279. onShow(() => {
  280. loggedIn.value = !!getToken()
  281. calcScrollHeight()
  282. if (!loggedIn.value) {
  283. groups.value = []
  284. return
  285. }
  286. loadCart()
  287. })
  288. </script>
  289. <style lang="scss" scoped>
  290. .cart-page {
  291. height: calc(100vh - 288rpx);
  292. background: #f5f6f8;
  293. padding-bottom: env(safe-area-inset-bottom);
  294. }
  295. .cart-guest,
  296. .cart-empty,
  297. .cart-loading,
  298. .cart-error {
  299. padding: 120rpx 48rpx;
  300. display: flex;
  301. flex-direction: column;
  302. align-items: center;
  303. }
  304. .cart-btn-primary {
  305. margin-top: 40rpx;
  306. width: 320rpx;
  307. height: 80rpx;
  308. line-height: 80rpx;
  309. background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
  310. color: #fff;
  311. font-size: 28rpx;
  312. border-radius: 40rpx;
  313. border: none;
  314. }
  315. .cart-retry {
  316. margin-top: 24rpx;
  317. font-size: 28rpx;
  318. color: #2e7d32;
  319. }
  320. .cart-toolbar {
  321. display: flex;
  322. justify-content: flex-end;
  323. gap: 24rpx;
  324. padding: 16rpx 24rpx;
  325. background: #fff;
  326. }
  327. .cart-toolbar__btn {
  328. font-size: 26rpx;
  329. color: #2e7d32;
  330. }
  331. .cart-scroll {
  332. padding: 16rpx 24rpx;
  333. box-sizing: border-box;
  334. }
  335. .cart-footer {
  336. position: fixed;
  337. left: 0;
  338. right: 0;
  339. bottom: 100rpx;
  340. display: flex;
  341. align-items: center;
  342. padding: 16rpx 24rpx;
  343. padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
  344. background: #fff;
  345. border-top: 1rpx solid #eee;
  346. z-index: 10;
  347. }
  348. .cart-footer__check {
  349. display: flex;
  350. align-items: center;
  351. margin-right: 16rpx;
  352. }
  353. .check-icon {
  354. width: 36rpx;
  355. height: 36rpx;
  356. border: 2rpx solid #ccc;
  357. border-radius: 50%;
  358. display: flex;
  359. align-items: center;
  360. justify-content: center;
  361. background: #fff;
  362. }
  363. .check-icon--on {
  364. background: #2e7d32;
  365. border-color: #2e7d32;
  366. }
  367. .cart-footer__label {
  368. margin-left: 8rpx;
  369. font-size: 26rpx;
  370. color: #333;
  371. }
  372. .cart-footer__sum {
  373. flex: 1;
  374. text-align: right;
  375. margin-right: 16rpx;
  376. }
  377. .cart-footer__sum-label {
  378. font-size: 26rpx;
  379. color: #333;
  380. }
  381. .cart-footer__sum-price {
  382. font-size: 34rpx;
  383. color: #e53935;
  384. font-weight: 600;
  385. }
  386. .cart-footer__btn {
  387. flex-shrink: 0;
  388. min-width: 200rpx;
  389. height: 72rpx;
  390. line-height: 72rpx;
  391. padding: 0 28rpx;
  392. background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
  393. color: #fff;
  394. font-size: 28rpx;
  395. border-radius: 36rpx;
  396. border: none;
  397. }
  398. .cart-footer__btn[disabled] {
  399. opacity: 0.5;
  400. }
  401. </style>