feat: 购物车

This commit is contained in:
jqtmviyu 2025-05-08 11:02:15 +08:00
parent 9308a1df85
commit 6935ad15d0
9 changed files with 640 additions and 12 deletions

View File

@ -0,0 +1,52 @@
import { Component } from '@uni-helper/uni-app-types'
/** 步进器 */
export type InputNumberBox = Component<InputNumberBoxProps>
/** 步进器实例 */
export type InputNumberBoxInstance = InstanceType<InputNumberBox>
/** 步进器属性 */
export type InputNumberBoxProps = {
/** 输入框初始值默认1 */
modelValue: number
/** 用户可输入的最小值默认0 */
min: number
/** 用户可输入的最大值默认99999 */
max: number
/** 步长每次加或减的值默认1 */
step: number
/** 是否禁用操作,包括输入框,加减按钮 */
disabled: boolean
/** 输入框宽度单位rpx默认80 */
inputWidth: string | number
/** 输入框和按钮的高度单位rpx默认50 */
inputHeight: string | number
/** 输入框和按钮的背景颜色(默认#F2F3F5 */
bgColor: string
/** 步进器标识符 */
index: string
/** 输入框内容发生变化时触发 */
change: (event: InputNumberBoxEvent) => void
/** 输入框失去焦点时触发 */
blur: (event: InputNumberBoxEvent) => void
/** 点击增加按钮时触发 */
plus: (event: InputNumberBoxEvent) => void
/** 点击减少按钮时触发 */
minus: (event: InputNumberBoxEvent) => void
}
/** 步进器事件对象 */
export type InputNumberBoxEvent = {
/** 输入框当前值 */
value: number
/** 步进器标识符 */
index: string
}
/** 全局组件类型声明 */
declare module 'vue' {
export interface GlobalComponents {
'vk-data-input-number-box': InputNumberBox
}
}

View File

@ -35,6 +35,12 @@
"navigationBarTitleText": "购物车"
}
},
{
"path": "pages/cart/goodsCart",
"style": {
"navigationBarTitleText": "购物车"
}
},
{
"path": "pages/my/my",
"style": {

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import cartMain from './components/cartMain.vue'
</script>
<template>
<view class="">test</view>
<cart-main />
</template>
<script setup lang="ts"></script>
<style lang="scss"></style>

View File

@ -0,0 +1,489 @@
<template>
<scroll-view scroll-y class="scroll-view">
<!-- 已登录: 显示购物车 -->
<template v-if="memberStore.profile">
<!-- 购物车列表 -->
<view class="cart-list" v-if="cartList.length > 0">
<!-- 优惠提示 -->
<view class="tips">
<text class="label">满减</text>
<text class="desc">满1件, 即可享受9折优惠</text>
</view>
<!-- 滑动操作分区 -->
<uni-swipe-action>
<!-- 滑动操作项 -->
<uni-swipe-action-item v-for="item in cartList" :key="item.skuId" class="cart-swipe">
<!-- 商品信息 -->
<view class="goods">
<!-- 选中状态 -->
<text
@tap="handlerSelect(item)"
class="checkbox"
:class="{ checked: item.selected }"
></text>
<navigator
:url="`/pages/goods/goods?id=${item.id}`"
hover-class="none"
class="navigator"
>
<image mode="aspectFill" class="picture" :src="item.picture"></image>
<view class="meta">
<view class="name ellipsis">{{ item.name }}</view>
<view class="attrsText ellipsis">{{ item.attrsText.replaceAll('', ' ') }}</view>
<view class="price">{{ item.nowPrice }}</view>
</view>
</navigator>
<!-- 商品数量 -->
<view class="count">
<vk-data-input-number-box
:model-value="item.count"
:index="item.skuId"
:min="1"
:max="item.stock"
:step="1"
@change="onChangeCount(item.count, $event)"
></vk-data-input-number-box>
</view>
</view>
<!-- 右侧删除按钮 -->
<template #right>
<view class="cart-swipe-right">
<button class="button delete-button" @tap="handlerDeleteCart(item.skuId)">
删除
</button>
</view>
</template>
</uni-swipe-action-item>
</uni-swipe-action>
</view>
<!-- 购物车空状态 -->
<view class="cart-blank" v-else>
<image src="/static/images/blank_cart.png" class="image" />
<text class="text">购物车还是空的快来挑选好货吧</text>
<navigator open-type="switchTab" url="/pages/index/index" hover-class="none">
<button class="button">去首页看看</button>
</navigator>
</view>
<!-- 吸底工具栏 -->
<view class="toolbar">
<text class="all" :class="{ checked: allSelected }" @tap="handlerAllSelect">全选</text>
<text class="text">合计:</text>
<text class="amount">{{ totalPrice.toFixed(2) }}</text>
<view class="button-grounp">
<view
class="button payment-button"
:class="{ disabled: selectedList.length === 0 }"
@tap="handlerPay"
>
去结算({{ totalCount }})
</view>
</view>
</view>
</template>
<!-- 未登录: 提示登录 -->
<view class="login-blank" v-else>
<text class="text">登录后可查看购物车中的商品</text>
<navigator url="/pages/login/login" hover-class="none">
<button class="button">去登录</button>
</navigator>
</view>
<!-- 猜你喜欢 -->
<XtxGuess ref="guessRef"></XtxGuess>
<!-- 底部占位空盒子 -->
<view class="toolbar-height"></view>
</scroll-view>
</template>
<script setup lang="ts">
import { useMemberStore } from '@/stores/modules/member'
import {
deleteMemberCartAPI,
getMemberCartAPI,
putMemberCartAPI,
putMemberCartSelectedAPI,
} from '@/services/cart'
import { onShow } from '@dcloudio/uni-app'
import type { CartItem } from '@/types/cart'
import { computed, ref } from 'vue'
import type { InputNumberBoxEvent } from '@/components/vk-data-input-number-box/vk-data-input-number-box'
import { debounce } from '@/utils/debounce-throttle'
const memberStore = useMemberStore()
//
const cartList = ref<CartItem[]>([])
//
const getMemberCart = async () => {
const res = await getMemberCartAPI()
console.log(res)
if (res.code === '1') {
cartList.value = res.result
}
}
onShow(() => {
if (memberStore.profile) {
getMemberCart()
}
})
//
const handlerDeleteCart = (skuId: string) => {
uni.showModal({
content: '确定要删除该商品吗?',
success: async (res) => {
if (res.confirm) {
const res = await deleteMemberCartAPI({ ids: [skuId] })
if (res.code === '1') {
getMemberCart()
}
}
},
})
}
//
//
const onChangeCount = debounce(async (count: number, event: InputNumberBoxEvent) => {
console.log(count, event)
if (count === event.value) return
const res = await putMemberCartAPI(event.index, { count: event.value })
if (res.code === '1') {
getMemberCart()
}
}, 200)
//
const handlerSelect = async (item: CartItem) => {
console.log(item)
//
item.selected = !item.selected
const res = await putMemberCartAPI(item.skuId, { selected: item.selected })
if (res.code === '1') {
getMemberCart()
}
}
//
const allSelected = computed(() => {
return cartList.value.length > 0 && cartList.value.every((item) => item.selected)
})
//
const handlerAllSelect = () => {
console.log('全选')
if (cartList.value.length === 0) return
//
const isSelected = !allSelected.value
//
cartList.value.forEach((item) => {
item.selected = isSelected
})
//
putMemberCartSelectedAPI({ selected: isSelected })
}
//
const selectedList = computed(() => {
return cartList.value.filter((item) => item.selected)
})
//
const totalCount = computed(() => {
return selectedList.value.reduce((total, item) => {
return total + item.count
}, 0)
})
//
const totalPrice = computed(() => {
return selectedList.value.reduce((total, item) => {
return total + item.nowPrice * item.count
}, 0)
})
//
const handlerPay = () => {
console.log('去结算')
if (selectedList.value.length === 0) {
uni.showToast({
title: '请选择商品',
icon: 'none',
})
return
}
uni.showToast({
title: '跳转结算页面占用',
icon: 'none',
})
}
</script>
<style lang="scss">
//
:host {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #f7f7f8;
}
//
.scroll-view {
flex: 1;
}
//
.cart-list {
padding: 0 20rpx;
//
.tips {
display: flex;
align-items: center;
line-height: 1;
margin: 30rpx 10rpx;
font-size: 26rpx;
color: #666;
.label {
color: #fff;
padding: 7rpx 15rpx 5rpx;
border-radius: 4rpx;
font-size: 24rpx;
background-color: #27ba9b;
margin-right: 10rpx;
}
}
//
.goods {
display: flex;
padding: 20rpx 20rpx 20rpx 80rpx;
border-radius: 10rpx;
background-color: #fff;
position: relative;
.navigator {
display: flex;
}
.checkbox {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 100%;
&::before {
content: '\e6cd';
font-family: 'erabbit' !important;
font-size: 40rpx;
color: #444;
}
&.checked::before {
content: '\e6cc';
color: #27ba9b;
}
}
.picture {
width: 170rpx;
height: 170rpx;
}
.meta {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
margin-left: 20rpx;
}
.name {
height: 72rpx;
font-size: 26rpx;
color: #444;
}
.attrsText {
line-height: 1.8;
padding: 0 15rpx;
font-size: 24rpx;
align-self: flex-start;
border-radius: 4rpx;
color: #888;
background-color: #f7f7f8;
}
.price {
line-height: 1;
font-size: 26rpx;
color: #444;
margin-bottom: 2rpx;
color: #cf4444;
&::before {
content: '¥';
font-size: 80%;
}
}
//
.count {
position: absolute;
bottom: 20rpx;
right: 5rpx;
}
}
.cart-swipe {
display: block;
margin: 20rpx 0;
}
.cart-swipe-right {
display: flex;
height: 100%;
.button {
display: flex;
justify-content: center;
align-items: center;
width: 50px;
padding: 6px;
line-height: 1.5;
color: #fff;
font-size: 26rpx;
border-radius: 0;
}
.delete-button {
background-color: #cf4444;
}
}
}
//
.cart-blank,
.login-blank {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 60vh;
.image {
width: 400rpx;
height: 281rpx;
}
.text {
color: #444;
font-size: 26rpx;
margin: 20rpx 0;
}
.button {
width: 240rpx !important;
height: 60rpx;
line-height: 60rpx;
margin-top: 20rpx;
font-size: 26rpx;
border-radius: 60rpx;
color: #fff;
background-color: #27ba9b;
}
}
//
.toolbar {
position: fixed;
left: 0;
right: 0;
bottom: var(--window-bottom);
z-index: 1;
height: 100rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
border-top: 1rpx solid #ededed;
border-bottom: 1rpx solid #ededed;
background-color: #fff;
box-sizing: content-box;
.all {
margin-left: 25rpx;
font-size: 14px;
color: #444;
display: flex;
align-items: center;
}
.all::before {
font-family: 'erabbit' !important;
content: '\e6cd';
font-size: 40rpx;
margin-right: 8rpx;
}
.checked::before {
content: '\e6cc';
color: #27ba9b;
}
.text {
margin-right: 8rpx;
margin-left: 32rpx;
color: #444;
font-size: 14px;
}
.amount {
font-size: 20px;
color: #cf4444;
.decimal {
font-size: 12px;
}
&::before {
content: '¥';
font-size: 12px;
}
}
.button-grounp {
margin-left: auto;
display: flex;
justify-content: space-between;
text-align: center;
line-height: 72rpx;
font-size: 13px;
color: #fff;
.button {
width: 240rpx;
margin: 0 10rpx;
border-radius: 72rpx;
}
.payment-button {
background-color: #27ba9b;
&.disabled {
opacity: 0.6;
}
}
}
}
//
.toolbar-height {
height: 100rpx;
}
</style>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import cartMain from './components/cartMain.vue'
</script>
<template>
<cart-main />
</template>

View File

@ -100,7 +100,7 @@
<button class="icons-button" open-type="contact">
<text class="icon-handset"></text>客服
</button>
<navigator class="icons-button" url="/pages/cart/cart" open-type="switchTab">
<navigator class="icons-button" url="/pages/cart/goodsCart" open-type="navigate">
<text class="icon-cart"></text>购物车
</navigator>
</view>
@ -144,7 +144,7 @@ import type {
SkuPopupInstance,
SkuPopupLocaldata,
} from '@/components/vk-data-goods-sku-popup/vk-data-goods-sku-popup'
import { postMemberCart } from '@/services/cart'
import { postMemberCartAPI } from '@/services/cart'
//
const { safeAreaInsets } = uni.getSystemInfoSync()
@ -256,7 +256,7 @@ const showSkuPopup = (mode: SkuMode) => {
//
const handlerAddCart = async (e: SkuPopupEvent) => {
const res = await postMemberCart({
const res = await postMemberCartAPI({
skuId: e._id,
count: e.buy_num,
})

View File

@ -84,9 +84,7 @@ const loginSucess = (profile: LoginResult) => {
icon: 'none',
})
setTimeout(() => {
uni.switchTab({
url: '/pages/my/my',
})
uni.navigateBack()
}, 500)
}
</script>

View File

@ -1,9 +1,62 @@
import type { CartItem } from '@/types/cart'
import { http } from '@/utils/http'
export const postMemberCart = (data: { skuId: string; count: number }) => {
/**
*
*/
export const postMemberCartAPI = (data: { skuId: string; count: number }) => {
return http({
url: '/member/cart',
method: 'POST',
data,
})
}
/**
*
*/
export const getMemberCartAPI = () => {
return http<CartItem[]>({
url: '/member/cart',
method: 'GET',
})
}
/**
*
*/
export const deleteMemberCartAPI = (data: { ids: string[] }) => {
return http({
url: '/member/cart',
method: 'DELETE',
data,
})
}
/**
*
*/
type PutMemberCartAPIParams = {
/** 是否选中 */
selected?: boolean
/** 商品数量 */
count?: number
}
export const putMemberCartAPI = (skuId: string, data: PutMemberCartAPIParams) => {
return http({
url: `/member/cart/${skuId}`,
method: 'PUT',
data,
})
}
/**
*
*/
export const putMemberCartSelectedAPI = (data: { selected: boolean }) => {
return http({
url: '/member/cart/selected',
method: 'PUT',
data,
})
}

View File

@ -0,0 +1,23 @@
// 防抖函数
export const debounce = (fn: Function, delay: number) => {
let timer: number | null = null
return function (...args: any[]) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
timer = null
}, delay)
}
}
// 节流函数
export const throttle = (fn: Function, delay: number) => {
let timer: number | null = null
return function (...args: any[]) {
if (timer) return
timer = setTimeout(() => {
fn(...args)
timer = null
}, delay)
}
}