巴青农资商城

index.vue 9.9KB

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