今日学习内容
地址管理页
类型声明
/** 添加收货地址: 请求参数 */
export type AddressParams = {
/** 收货人姓名 */
receiver: string
/** 联系方式 */
contact: string
/** 省份编码 */
provinceCode: string
/** 城市编码 */
cityCode: string
/** 区/县编码 */
countyCode: string
/** 详细地址 */
address: string
/** 默认地址,1为是,0为否 */
isDefault: number
}
/** 收货地址项 */
export type AddressItem = {
/** 收货人姓名 */
receiver: string
/** 联系方式 */
contact: string
/** 省份编码 */
provinceCode: string
/** 城市编码 */
cityCode: string
/** 区/县编码 */
countyCode: string
/** 详细地址 */
address: string
/** 默认地址,1为是,0为否 */
isDefault: number
/** 收货地址 id */
id: string
/** 省市区 */
fullLocation: string
}
接口声明
import type { AddressItem, AddressParams } from '@/types/address'
import { http } from '@/utils/http'
/**
* 添加收货地址
* @param data 请求参数
*/
export const postMemberAddressAPI = (data: AddressParams) => {
return http({
method: 'POST',
url: '/member/address',
data,
})
}
/**
* 获取收货地址列表
*/
export const getMemberAddressAPI = () => {
return http<AddressItem[]>({
method: 'GET',
url: '/member/address',
})
}
/**
* 获取收货地址详情
* @param id 地址id(路径参数)
*/
export const getMemberAddressByIdAPI = (id: string) => {
return http<AddressItem>({
method: 'GET',
url: `/member/address/${id}`,
})
}
/**
* 修改收货地址
* @param id 地址id(路径参数)
* @param data 表单数据(请求体参数)
*/
export const putMemberAddressByIdAPI = (id: string, data: AddressParams) => {
return http({
method: 'PUT',
url: `/member/address/${id}`,
data,
})
}
/**
* 删除收货地址
* @param id 地址id(路径参数)
*/
export const deleteMemberAddressByIdAPI = (id: string) => {
return http({
method: 'DELETE',
url: `/member/address/${id}`,
})
}
组件代码
<script setup lang="ts">
import { getMemberAddressAPI, deleteMemberAddressByIdAPI } from '@/services/address'
import type { AddressItem } from '@/types/address'
import { onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
// 获取收货地址列表数据
const addressList = ref<AddressItem[]>([])
const getMemberAddressData = async () => {
const res = await getMemberAddressAPI()
addressList.value = res.result
}
// 初始化调用(页面显示)
onShow(() => {
getMemberAddressData()
})
const onDeleteAddress = (id: string) => {
deleteMemberAddressByIdAPI(id)
uni.showToast({ icon: 'success', title: '操作成功~' })
getMemberAddressData()
}
</script>
<template>
<view class="viewport">
<!-- 地址列表 -->
<scroll-view class="scroll-view" scroll-y>
<view v-if="true" class="address">
<uni-swipe-action class="address-list">
<!-- 收获地址项 -->
<uni-swipe-action-item class="item" v-for="item in addressList" :key="item.id">
<view class="item-content">
<view class="user">
{{ item.receiver }}
<text class="contact">{{ item.contact }}</text>
<text v-if="item.isDefault" class="badge">默认</text>
</view>
<view class="locate">{{ item.fullLocation }} {{ item.address }}</view>
<navigator class="edit" hover-class="none" :url="`/pagesMember/address-form/address-form?id=${item.id}`">
修改
</navigator>
</view>
<!-- 右侧插槽 -->
<template #right>
<button @tap="onDeleteAddress(item.id)" class="delete-button">删除</button>
</template>
</uni-swipe-action-item>
</uni-swipe-action>
</view>
<view v-else class="blank">暂无收货地址</view>
</scroll-view>
<!-- 添加按钮 -->
<view class="add-btn">
<navigator hover-class="none" url="/pagesMember/address-form/address-form">
新建地址
</navigator>
</view>
</view>
</template>
<style lang="scss">
page {
height: 100%;
overflow: hidden;
}
/* 删除按钮 */
.delete-button {
display: flex;
justify-content: center;
align-items: center;
width: 50px;
height: 100%;
font-size: 28rpx;
color: #fff;
border-radius: 0;
padding: 0;
background-color: #cf4444;
}
.viewport {
display: flex;
flex-direction: column;
height: 100%;
background-color: #f4f4f4;
.scroll-view {
padding-top: 20rpx;
}
}
.address {
padding: 0 20rpx;
margin: 0 20rpx;
border-radius: 10rpx;
background-color: #fff;
.item-content {
line-height: 1;
padding: 40rpx 10rpx 38rpx;
border-bottom: 1rpx solid #ddd;
position: relative;
.edit {
position: absolute;
top: 36rpx;
right: 30rpx;
padding: 2rpx 0 2rpx 20rpx;
border-left: 1rpx solid #666;
font-size: 26rpx;
color: #666;
line-height: 1;
}
}
.item:last-child .item-content {
border: none;
}
.user {
font-size: 28rpx;
margin-bottom: 20rpx;
color: #333;
.contact {
color: #666;
}
.badge {
display: inline-block;
padding: 4rpx 10rpx 2rpx 14rpx;
margin: 2rpx 0 0 10rpx;
font-size: 26rpx;
color: #27ba9b;
border-radius: 6rpx;
border: 1rpx solid #27ba9b;
}
}
.locate {
line-height: 1.6;
font-size: 26rpx;
color: #333;
}
}
.blank {
margin-top: 300rpx;
text-align: center;
font-size: 32rpx;
color: #888;
}
.add-btn {
height: 80rpx;
text-align: center;
line-height: 80rpx;
margin: 30rpx 20rpx;
color: #fff;
border-radius: 80rpx;
font-size: 30rpx;
background-color: #27ba9b;
}
</style>
<script setup lang="ts">
import {
getMemberAddressByIdAPI,
postMemberAddressAPI,
putMemberAddressByIdAPI,
} from '@/services/address'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
// 表单数据
const form = ref({
receiver: '', // 收货人
contact: '', // 联系方式
fullLocation: '', // 省市区(前端展示)
provinceCode: '', // 省份编码(后端参数)
cityCode: '', // 城市编码(后端参数)
countyCode: '', // 区/县编码(后端参数)
address: '', // 详细地址
isDefault: 0, // 默认地址,1为是,0为否
})
// 收集所在地区
const onRegionChange: UniHelper.RegionPickerOnChange = (ev) => {
// 省市区(前端展示)
form.value.fullLocation = ev.detail.value.join(' ')
// 省市区(后端参数)
const [provinceCode, cityCode, countyCode] = ev.detail.code!
// 合并数据
Object.assign(form.value, { provinceCode, cityCode, countyCode })
}
// 收集是否默认收货地址
const onSwitchChange: UniHelper.SwitchOnChange = (ev) => {
form.value.isDefault = ev.detail.value ? 1 : 0
}
// 提交表单
// 提交表单
const onSubmit = async () => {
try {
await formRef.value?.validate?.()
// 判断当前页面是否有地址 id
if (query.id) {
// 修改地址请求
await putMemberAddressByIdAPI(query.id, form.value)
} else {
// 新建地址请求
await postMemberAddressAPI(form.value)
}
// 成功提示
uni.showToast({ icon: 'success', title: query.id ? '修改成功' : '添加成功' })
// 返回上一页
setTimeout(() => {
uni.navigateBack()
}, 400)
} catch (error) {
uni.showToast({ icon: 'error', title: '请填写完整信息' })
}
}
// 获取页面参数
const query = defineProps<{
id?: string
}>()
// 动态设置标题
uni.setNavigationBarTitle({ title: query.id ? '修改地址' : '新建地址' })
// 获取收货地址详情数据
const getMemberAddressByIdData = async () => {
// 有 id 才调用接口
if (query.id) {
// 发送请求
const res = await getMemberAddressByIdAPI(query.id)
// 把数据合并到表单中
Object.assign(form.value, res.result)
}
}
// 页面加载
onLoad(() => {
getMemberAddressByIdData()
})
// 定义校验规则
const rules: UniHelper.UniFormsRules = {
receiver: {
rules: [{ required: true, errorMessage: '请输入收货人姓名' }],
},
contact: {
rules: [
{ required: true, errorMessage: '请输入联系方式' },
{ pattern: /^1[3-9]\d{9}$/, errorMessage: '手机号格式不正确' },
],
},
fullLocation: {
rules: [{ required: true, errorMessage: '请选择所在地区' }],
},
address: {
rules: [{ required: true, errorMessage: '请选择详细地址' }],
},
}
// 获取表单组件实例,用于调用表单方法
const formRef = ref<UniHelper.UniFormsInstance>()
</script>
<template>
<view class="content">
<uni-forms :rules="rules" :model="form" ref="formRef">
<!-- 表单内容 -->
<uni-forms-item name="receiver" class="form-item">
<text class="label">收货人</text>
<input class="input" placeholder="请填写收货人姓名" v-model="form.receiver" />
</uni-forms-item>
<uni-forms-item name="contact" class="form-item">
<text class="label">手机号码</text>
<input
class="input"
placeholder="请填写收货人手机号码"
:maxlength="11"
v-model="form.contact"
/>
</uni-forms-item>
<uni-forms-item name="fullLocation" class="form-item">
<text class="label">所在地区</text>
<picker
class="picker"
@change="onRegionChange"
mode="region"
:value="form.fullLocation.split(' ')"
>
<view v-if="form.fullLocation">{{ form.fullLocation }}</view>
<view v-else class="placeholder">请选择省/市/区(县)</view>
</picker>
</uni-forms-item>
<uni-forms-item name="address" class="form-item">
<text class="label">详细地址</text>
<input class="input" placeholder="街道、楼牌号等信息" v-model="form.address" />
</uni-forms-item>
<view class="form-item">
<label class="label">设为默认地址</label>
<switch
class="switch"
color="#27ba9b"
@change="onSwitchChange"
:checked="form.isDefault === 1"
/>
</view>
</uni-forms>
</view>
<!-- 提交按钮 -->
<button @tap="onSubmit" class="button">保存并使用</button>
</template>
<style lang="scss">
page {
background-color: #f4f4f4;
}
.content {
margin: 20rpx 20rpx 0;
padding: 0 20rpx;
border-radius: 10rpx;
background-color: #fff;
.form-item,
.uni-forms-item {
display: flex;
align-items: center;
min-height: 96rpx;
padding: 25rpx 10rpx 40rpx;
background-color: #fff;
font-size: 28rpx;
border-bottom: 1rpx solid #ddd;
position: relative;
margin-bottom: 0;
// 调整 uni-forms 样式
.uni-forms-item__content {
display: flex;
}
.uni-forms-item__error {
margin-left: 200rpx;
}
&:last-child {
border: none;
}
.label {
width: 200rpx;
color: #333;
}
.input {
flex: 1;
display: block;
height: 46rpx;
}
.switch {
position: absolute;
right: -20rpx;
transform: scale(0.8);
}
.picker {
flex: 1;
}
.placeholder {
color: #808080;
}
}
}
.button {
height: 80rpx;
margin: 30rpx 20rpx;
color: #fff;
border-radius: 80rpx;
font-size: 30rpx;
background-color: #27ba9b;
}
</style>
Sku模块
导入组件
<template>
<view class="vk-data-goods-sku-popup" catchtouchmove="true" :class="valueCom && complete ? 'show' : 'none'"
@touchmove.stop.prevent="moveHandle" @click.stop="stop">
<!-- 页面内容开始 -->
<view class="mask" @click="close('mask')"></view>
<view class="layer attr-content" :class="{ 'safe-area-inset-bottom': safeAreaInsetBottom }"
:style="{ borderRadius: borderRadius + 'rpx ' + borderRadius + 'rpx 0 0' }">
<view class="specification-wrapper">
<scroll-view class="specification-wrapper-content" scroll-y="true">
<view class="specification-header">
<view class="specification-left">
<image class="product-img" :src="selectShop.image ? selectShop.image : goodsInfo[goodsThumbName]"
:style="{ backgroundColor: goodsThumbBackgroundColor }" mode="aspectFill" @click="previewImage"></image>
</view>
<view class="specification-right">
<view class="price-content" :style="{ color: themeColorFn('priceColor') }">
<text class="sign">¥</text>
<text class="price" :class="priceCom.length > 16 ? 'price2' : ''">{{ priceCom }}</text>
</view>
<view class="inventory" v-if="!hideStock">{{ stockText }}:{{ stockCom }}</view>
<view class="inventory" v-else></view>
<view class="choose" v-show="isManyCom">已选:{{ selectArr.join(' ') }}</view>
</view>
</view>
<view class="specification-content">
<view v-show="isManyCom" class="specification-item" v-for="(item, index1) in goodsInfo[specListName]"
:key="index1">
<view class="item-title">{{ item.name }}</view>
<view class="item-wrapper">
<view class="item-content" v-for="(item_value, index2) in item.list" :key="index2"
:class="[item_value.ishow ? '' : 'noactived', subIndex[index1] == index2 ? 'actived' : '']" :style="[
item_value.ishow ? '' : themeColorFn('disableStyle'),
item_value.ishow ? themeColorFn('btnStyle') : '',
subIndex[index1] == index2 ? themeColorFn('activedStyle') : ''
]" @click="skuClick(item_value, index1, index2)">
{{ item_value.name }}
</view>
</view>
</view>
<view class="number-box-view">
<view style="flex: 1;">数量</view>
<view style="flex: 4;text-align: right;">
<vk-data-input-number-box v-model="selectNum" :min="minBuyNum || 1" :max="maxBuyNumCom"
:step="stepBuyNum || 1" :step-strictly="stepStrictly" :positive-integer="true"
@change="numChange"></vk-data-input-number-box>
</view>
</view>
</view>
</scroll-view>
<view class="close" @click="close('close')" v-if="showClose != false">
<image class="close-item" :src="closeImage"></image>
</view>
</view>
<view class="btn-wrapper" v-if="outFoStock || mode == 4">
<view class="sure" style="color:#ffffff;background-color:#cccccc">{{ noStockText }}</view>
</view>
<view class="btn-wrapper" v-else-if="mode == 1">
<view class="sure add-cart" style="border-radius:38rpx 0rpx 0rpx 38rpx;" :style="{
color: themeColorFn('addCartColor'),
backgroundColor: themeColorFn('addCartBackgroundColor')
}" @click="addCart">
{{ addCartText }}
</view>
<view class="sure" style="border-radius:0rpx 38rpx 38rpx 0rpx;" :style="{
color: themeColorFn('buyNowColor'),
backgroundColor: themeColorFn('buyNowBackgroundColor')
}" @click="buyNow">
{{ buyNowText }}
</view>
</view>
<view class="btn-wrapper" v-else-if="mode == 2">
<view class="sure add-cart" :style="{
color: themeColorFn('addCartColor'),
backgroundColor: themeColorFn('addCartBackgroundColor')
}" @click="addCart">
{{ addCartText }}
</view>
</view>
<view class="btn-wrapper" v-else-if="mode == 3">
<view class="sure" :style="{
color: themeColorFn('buyNowColor'),
backgroundColor: themeColorFn('buyNowBackgroundColor')
}" @click="buyNow">
{{ buyNowText }}
</view>
</view>
</view>
<!-- 页面内容结束 -->
</view>
</template>
<script>
var vk; // vk依赖
var goodsCache = {}; // 本地商品缓存
export default {
name: 'vk-data-goods-sku-popup',
emits: ['update:modelValue', 'input', 'update-goods', 'open', 'close', 'add-cart', 'buy-now', 'cart', 'buy', 'num-change'],
props: {
// true 组件显示 false 组件隐藏
value: {
Type: Boolean,
default: false
},
modelValue: {
Type: Boolean,
default: false
},
// vk云函数路由模式参数开始-----------------------------------------------------------
// 商品id
goodsId: {
Type: String,
default: ''
},
// vk路由模式框架下的云函数地址
action: {
Type: String,
default: ''
},
// vk云函数路由模式参数结束-----------------------------------------------------------
// 该商品已抢完时的按钮文字
noStockText: {
Type: String,
default: '该商品已抢完'
},
// 库存文字
stockText: {
Type: String,
default: '库存'
},
// 商品表id的字段名
goodsIdName: {
Type: String,
default: '_id'
},
// sku表id的字段名
skuIdName: {
Type: String,
default: '_id'
},
// sku_list的字段名
skuListName: {
Type: String,
default: 'sku_list'
},
// spec_list的字段名
specListName: {
Type: String,
default: 'spec_list'
},
// 库存的字段名 默认 stock
stockName: {
Type: String,
default: 'stock'
},
// sku组合路径的字段名
skuArrName: {
Type: String,
default: 'sku_name_arr'
},
// 默认单规格时的规格组名称
defaultSingleSkuName: {
Type: String,
default: '默认'
},
// 模式 1:都显示 2:只显示购物车 3:只显示立即购买 4:显示缺货按钮 默认 1
mode: {
Type: Number,
default: 1
},
// 点击遮罩是否关闭组件 true 关闭 false 不关闭 默认true
maskCloseAble: {
Type: Boolean,
default: true
},
// 顶部圆角值
borderRadius: {
Type: [String, Number],
default: 0
},
// 商品缩略图字段名(未选择sku时)
goodsThumbName: {
Type: [String],
default: 'goods_thumb'
},
// 商品缩略图背景颜色,如#999999
goodsThumbBackgroundColor: {
Type: String,
default: 'transparent'
},
// 最小购买数量 默认 1
minBuyNum: {
Type: [Number, String],
default: 1
},
// 最大购买数量 默认 100000
maxBuyNum: {
Type: [Number, String],
default: 100000
},
// 步进器步长 默认 1
stepBuyNum: {
Type: [Number, String],
default: 1
},
// 是否只能输入 step 的倍数
stepStrictly: {
Type: Boolean,
default: false
},
// 自定义获取商品信息的函数,支付宝小程序不支持该属性,请使用localdata属性
customAction: {
Type: [Function],
default: null
},
// 本地数据源
localdata: {
type: Object
},
// 价格的字体颜色
priceColor: {
Type: String
},
// 立即购买按钮的文字
buyNowText: {
Type: String,
default: '立即购买'
},
// 立即购买按钮的字体颜色
buyNowColor: {
Type: String
},
// 立即购买按钮的背景颜色
buyNowBackgroundColor: {
Type: String
},
// 加入购物车按钮的文字
addCartText: {
Type: String,
default: '加入购物车'
},
// 加入购物车按钮的字体颜色
addCartColor: {
Type: String
},
// 加入购物车按钮的背景颜色
addCartBackgroundColor: {
Type: String
},
// 不可点击时,按钮的样式
disableStyle: {
Type: Object,
default: null
},
// 按钮点击时的样式
activedStyle: {
Type: Object,
default: null
},
// 按钮常态的样式
btnStyle: {
Type: Object,
default: null
},
// 是否显示右上角关闭按钮
showClose: {
Type: Boolean,
default: true
},
// 关闭按钮的图片地址 https://img.alicdn.com/imgextra/i1/121022687/O1CN01ImN0O11VigqwzpLiK_!!121022687.png
closeImage: {
Type: String,
default:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACoAAAAqCAYAAADFw8lbAAAEyUlEQVR42sSZeWwNURTGp4OqtBo7sSXELragdkpQsRRJ1Zr4hyJiJ9YgxNIg1qANiT+E1i5IY0kVVWtQEbuEKLFGUSH27/ANN5PXmTvzupzkl/tm8t6b7517lnvvC0lKSjJ8WmnQAUSDFqABqALKgl8gD7wE90E2SAeXwFf1SxISErQeVtKHwCgwFsSDSIf3hYFKoCkYDBaDdyAViHdueHmoF6FtwDLQ23b/E7gM7oIcejIERIDaoBFoC8qA8mA8SQNz6W1XC9GY+nCQCCYAk/c+gF0gBZwH312+IxR0BCPBUIaH2A+wHsxHCHxx+gLT5QGN6a2JfG8uvVCDws9oiDQYlxkMGfHyQvARlADTwcXk5OT6foV2kS8ATXidymlcyen1a/Jjl9IJh3hPkjELYqO8Cu0KjjNZvtETw5jFBWXPmGSTGQKSeOn5iQ0kVLL0CINfPNcPbDMKyRCbGzEMBJ+ZD8cChYFdqGTqfsWT8otPGoVsEHsMwxDFs3shNsxJ6BrQ0Po8OGUUkVHsNCVml+cntB1jUWwn2GEUsTEMrASbDK+2CCQ0kYX6nfLLisMmKqUr0S60M+jG10vAm+JSCa8+x7CKlzHwaktV6DiObzUzPJIxFO1BQ12wGtTReO9GetVgY/kjNJzZbcWmTjHfxw51AsRqvL8eOAtmsJuFu3g1l+1ZLB5eDTVZ3K0P7tL0TkWOpSg61kVkBtuuNRthGs+wtJST5aQI7cEbkkRXNYVKgX6kIdYuUhYzMQwxN8tiExCLFqHNeSF9/aem0BzGp5PYQCJ7c/Gsk1RfuSD6U1dNpcDf9ZigTmKbMRZ9iVTsHscGJluW2FMf1SSQWGnBmaB6kCJVTVVNJZE++Cx9drEllS1KMCINpURFmEbBWA63Fz9s95cGIdJgp/zXmT4pZcOvSUzuZttTbblmnc3PIjjmidDXvKgdhMh0JdbzuCjWrbNOVovjS5P7bkPJ/mBESkz2BO0166ybNeJ431S2q+01NntuIq3E0amzjiZtk9tssWyTDzO4525bACK9NAUn68TtkNhpEXpOSagRml+S6iLSSeweHv242Qhl13rRyvoDvDlKyTQny/ZQJ+1iH7vVbEx7OR5UiKVIO7VicgvHCtwrudloMIV7/0uadVYW57O4Wvvi8v4pymlKkrpwvsDeLLZAY2pkwbAB3PSQfC+4cH7l4k1ZH8zkZRq8ecO+Z5rN40JJqnXFuGfaxPCTLjcn0OZOpnArXw8HY4paIbw5CcMgXq6HN2/mt6+XGLrN15tBryIUGavMpCTrfKcDCKkAceA9S8nhAOehhSUyhXpkBxxnP4YM1InugP7cBkjBPcqVUWFYCEROxXiQz5JlXV+IfKh7mpfJac+lZ6V87QXVClBkTc7YWsWTPSDyitfzUTlJlj8TbvE6jluDOdwZ+jX57GLO3ADeuyZrDYi86vV81FD2UVGsmT+5Zl0BnkhoseOEaogL46pqO4v/IqUEyalIR4h85BgjHv6+aUWRMbb7EstX6O0cpT1Gco0ry8fWygLDMjmDnQeBt3Qe7uVfkeugDwVLcsVzGsuwLXbV+I63XNAkG5r/hvgRqgqWs6pJPKrsbvz/Q6yyun0w/h6lP+BnzrCpfPMT2L8FGAA7k1GZ/vnaqAAAAABJRU5ErkJggg=='
},
// 是否隐藏库存显示
hideStock: {
Type: Boolean,
default: false
},
// 颜色主题
theme: {
Type: String,
default: 'default'
},
// 请求中的提示
actionTips: {
Type: String,
default: '请求中...'
},
// 默认选中的SKU
defaultSelect: {
Type: Object
},
// 是否使用缓存
useCache: {
Type: Boolean,
default: true
},
/**
* 默认商品,设置该值可快速展示商品
* 逻辑: 先展示 defaultGoods 信息,再取数据库,再更新页面(通常为更新库存)
*/
defaultGoods: {
Type: Object
},
/**
* 金额是否需要除以100
* 1:金额会除以100
* 0:金额不会除以100
*/
amountType: {
Type: Number,
default: 1
},
// 每次选择完SKU后,购买数量归1,如果有最小购买数量,则设置为最小购买数量
selectedInit: {
Type: Boolean,
default: false
},
// 是否开启底部安全区适配,默认true
safeAreaInsetBottom: {
Type: Boolean,
default: true
},
},
data() {
return {
complete: false, // 组件是否加载完成
goodsInfo: {}, // 商品信息
isShow: false, // true 显示 false 隐藏
initKey: true, // 是否需要初始化 true 是 false 否
// #ifndef MP-BAIDU
shopItemInfo: {}, // 存放要和选中的值进行匹配的数据(因百度小程序setData不支持中文字段,故不编译shopItemInfo变量)
// #endif
selectArr: [], // 存放被选中的值
subIndex: [], // 是否选中 因为不确定是多规格还是单规格,所以这里定义数组来判断
selectShop: {}, // 存放最后选中的商品
selectNum: this.minBuyNum || 1, // 选中数量
outFoStock: false, // 是否全部sku都缺货
openTime: 0,
themeColor: {
// 默认主题
default: {
priceColor: 'rgb(254, 86, 10)',
buyNowColor: '#ffffff',
buyNowBackgroundColor: 'rgb(254, 86, 10)',
addCartColor: '#ffffff',
addCartBackgroundColor: 'rgb(255, 148, 2)',
btnStyle: {
color: '#333333',
borderColor: '#f4f4f4',
backgroundColor: '#ffffff'
},
activedStyle: {
color: 'rgb(254, 86, 10)',
borderColor: 'rgb(254, 86, 10)',
backgroundColor: 'rgba(254,86,10,0.1)'
},
disableStyle: {
color: '#c3c3c3',
borderColor: '#f6f6f6',
backgroundColor: '#f6f6f6'
}
},
// 红黑主题
'red-black': {
priceColor: 'rgb(255, 68, 68)',
buyNowColor: '#ffffff',
buyNowBackgroundColor: 'rgb(255, 68, 68)',
addCartColor: '#ffffff',
addCartBackgroundColor: 'rgb(85, 85, 85)',
activedStyle: {
color: 'rgb(255, 68, 68)',
borderColor: 'rgb(255, 68, 68)',
backgroundColor: 'rgba(255,68,68,0.1)'
}
},
// 黑白主题
'black-white': {
priceColor: 'rgb(47, 47, 52)',
buyNowColor: '#ffffff',
buyNowBackgroundColor: 'rgb(47, 47, 52)',
addCartColor: 'rgb(47, 47, 52)',
addCartBackgroundColor: 'rgb(235, 236, 242)',
// btnStyle:{
// color:"rgb(47, 47, 52)",
// borderColor:"rgba(235,236,242,0.5)",
// backgroundColor:"rgba(235,236,242,0.5)",
// },
activedStyle: {
color: 'rgb(47, 47, 52)',
borderColor: 'rgba(47,47,52,0.12)',
backgroundColor: 'rgba(47,47,52,0.12)'
}
},
// 咖啡色主题
coffee: {
priceColor: 'rgb(195, 167, 105)',
buyNowColor: '#ffffff',
buyNowBackgroundColor: 'rgb(195, 167, 105)',
addCartColor: 'rgb(195, 167, 105)',
addCartBackgroundColor: 'rgb(243, 238, 225)',
activedStyle: {
color: 'rgb(195, 167, 105)',
borderColor: 'rgb(195, 167, 105)',
backgroundColor: 'rgba(195, 167, 105,0.1)'
}
},
// 浅绿色主题
green: {
priceColor: 'rgb(99, 190, 114)',
buyNowColor: '#ffffff',
buyNowBackgroundColor: 'rgb(99, 190, 114)',
addCartColor: 'rgb(99, 190, 114)',
addCartBackgroundColor: 'rgb(225, 244, 227)',
activedStyle: {
color: 'rgb(99, 190, 114)',
borderColor: 'rgb(99, 190, 114)',
backgroundColor: 'rgba(99, 190, 114,0.1)'
}
}
}
};
},
created() {
let that = this;
vk = that.vk;
if (that.valueCom) {
that.open();
}
},
mounted() { },
methods: {
// 初始化
init(notAutoClick) {
let that = this;
// 清空之前的数据
that.selectArr = [];
that.subIndex = [];
that.selectShop = {};
that.selectNum = that.minBuyNum || 1;
that.outFoStock = false;
that.shopItemInfo = {};
let specListName = that.specListName;
that.goodsInfo[specListName].map(item => {
that.selectArr.push('');
that.subIndex.push(-1);
});
that.checkItem(); // 计算sku里面规格形成路径
that.checkInpath(-1); // 传-1是为了不跳过循环
if (!notAutoClick) that.autoClickSku(); // 自动选择sku策略
},
// 使用vk路由模式框架获取商品信息
findGoodsInfo(obj = {}) {
let that = this;
let { useCache } = obj;
if (typeof vk == 'undefined') {
that.toast('custom-action必须是function', 'none');
return false;
}
let { actionTips } = that;
let actionTitle = '';
let actionAoading = false;
if (actionTips !== 'custom') {
actionTitle = useCache ? '' : '请求中...';
} else {
actionAoading = useCache ? false : true;
}
vk.callFunction({
url: that.action,
title: actionTitle,
loading: actionAoading,
data: {
goods_id: that.goodsId
},
success(data) {
that.updateGoodsInfo(data.goodsInfo);
// 更新缓存
goodsCache[that.goodsId] = data.goodsInfo;
that.$emit('update-goods', data.goodsInfo);
},
fail() {
that.updateValue(false);
}
});
},
updateValue(value) {
let that = this;
if (value) {
that.$emit('open', true);
that.$emit('input', true);
that.$emit('update:modelValue', true);
} else {
that.$emit('input', false);
that.$emit('close', 'close');
that.$emit('update:modelValue', false);
}
},
// 更新商品信息(库存、名称、图片)
updateGoodsInfo(goodsInfo) {
let that = this;
// goodsInfo.sku_list.map((item, index) => {
// item.sku_name_arr = ["20ml/瓶"];
// });
let { skuListName } = that;
if (JSON.stringify(that.goodsInfo) === '{}' || that.goodsInfo[that.goodsIdName] !== goodsInfo[that.goodsIdName]) {
that.goodsInfo = goodsInfo;
that.initKey = true;
} else {
that.goodsInfo[skuListName] = goodsInfo[skuListName];
}
if (that.initKey) {
that.initKey = false;
that.init();
}
// 更新选中sku的库存信息
let select_sku_info = that.getListItem(that.goodsInfo[skuListName], that.skuIdName, that.selectShop[that.skuIdName]);
Object.assign(that.selectShop, select_sku_info);
that.defaultSelectSku();
that.complete = true;
},
async open() {
let that = this;
that.openTime = new Date().getTime();
let findGoodsInfoRun = true;
let skuListName = that.skuListName;
// 先获取缓存中的商品信息
let useCache = false;
let goodsInfo = goodsCache[that.goodsId];
if (goodsInfo && that.useCache) {
useCache = true;
that.updateGoodsInfo(goodsInfo);
} else {
that.complete = false;
}
if (that.customAction && typeof that.customAction === 'function') {
try {
goodsInfo = await that
.customAction({
useCache,
goodsId: that.goodsId,
goodsInfo,
close: function () {
setTimeout(function () {
that.close();
}, 500);
}
})
.catch(err => {
setTimeout(function () {
that.close();
}, 500);
});
} catch (err) {
let { message = '' } = err;
if (message.indexOf('.catch is not a function') > -1) {
that.toast('custom-action必须返回一个Promise', 'none');
setTimeout(function () {
that.close();
}, 500);
return false;
}
}
// 更新缓存
goodsCache[that.goodsId] = goodsInfo;
if (goodsInfo && typeof goodsInfo == 'object' && JSON.stringify(goodsInfo) != '{}') {
findGoodsInfoRun = false;
that.updateGoodsInfo(goodsInfo);
that.updateValue(true);
} else {
that.toast('未获取到商品信息', 'none');
that.$emit('input', false);
return false;
}
} else if (typeof that.localdata !== 'undefined' && that.localdata !== null) {
goodsInfo = that.localdata;
if (goodsInfo && typeof goodsInfo == 'object' && JSON.stringify(goodsInfo) != '{}') {
findGoodsInfoRun = false;
that.updateGoodsInfo(goodsInfo);
that.updateValue(true);
} else {
that.toast('未获取到商品信息', 'none');
that.$emit('input', false);
return false;
}
} else {
if (findGoodsInfoRun) that.findGoodsInfo({ useCache });
}
},
// 监听 - 弹出层收起
close(s) {
let that = this;
if (new Date().getTime() - that.openTime < 400) {
return false;
}
if (s == 'mask') {
if (that.maskCloseAble !== false) {
that.$emit('input', false);
that.$emit('close', 'mask');
that.$emit('update:modelValue', false);
}
} else {
that.$emit('input', false);
that.$emit('close', 'close');
that.$emit('update:modelValue', false);
}
},
moveHandle() {
//禁止父元素滑动
},
// sku按钮的点击事件
skuClick(value, index1, index2) {
let that = this;
if (value.ishow) {
if (that.selectArr[index1] != value.name) {
that.$set(that.selectArr, index1, value.name);
that.$set(that.subIndex, index1, index2);
} else {
that.$set(that.selectArr, index1, '');
that.$set(that.subIndex, index1, -1);
}
that.checkInpath(index1);
// 如果全部选完
that.checkSelectShop();
}
},
// 检测是否已经选完sku
checkSelectShop() {
let that = this;
// 如果全部选完
if (that.selectArr.every(item => item != '')) {
that.selectShop = that.shopItemInfo[that.getArrayToSting(that.selectArr)];
let stock = that.selectShop[that.stockName];
if (typeof stock !== 'undefined' && that.selectNum > stock) {
that.selectNum = stock;
}
if (that.selectNum > that.maxBuyNum) {
that.selectNum = that.maxBuyNum;
}
if (that.selectNum < that.minBuyNum) {
that.selectNum = that.minBuyNum;
}
if (that.selectedInit) {
that.selectNum = that.minBuyNum || 1;
}
} else {
that.selectShop = {};
}
},
// 检查路径
checkInpath(clickIndex) {
let that = this;
let specListName = that.specListName;
//console.time('筛选可选路径需要的时间是');
//循环所有属性判断哪些属性可选
//当前选中的兄弟节点和已选中属性不需要循环
let specList = that.goodsInfo[specListName];
for (let i = 0, len = specList.length; i < len; i++) {
if (i == clickIndex) {
continue;
}
let len2 = specList[i].list.length;
for (let j = 0; j < len2; j++) {
if (that.subIndex[i] != -1 && j == that.subIndex[i]) {
continue;
}
let choosed_copy = [...that.selectArr];
that.$set(choosed_copy, i, specList[i].list[j].name);
let choosed_copy2 = choosed_copy.filter(item => item !== '' && typeof item !== 'undefined');
if (that.shopItemInfo.hasOwnProperty(that.getArrayToSting(choosed_copy2))) {
specList[i].list[j].ishow = true;
} else {
specList[i].list[j].ishow = false;
}
}
}
that.$set(that.goodsInfo, specListName, specList);
// console.timeEnd('筛选可选路径需要的时间是');
},
// 计算sku里面规格形成路径
checkItem() {
let that = this;
// console.time('计算有多小种可选路径需要的时间是');
let { stockName } = that;
let skuListName = that.skuListName;
// 去除库存小于等于0的商品sku
let originalSkuList = that.goodsInfo[skuListName];
let skuList = [];
let stockNum = 0;
originalSkuList.map((skuItem, index) => {
if (skuItem[stockName] > 0) {
skuList.push(skuItem);
stockNum += skuItem[stockName];
}
});
if (stockNum <= 0) {
that.outFoStock = true;
}
// 计算有多小种可选路径
let result = skuList.reduce(
(arrs, items) => {
return arrs.concat(
items[that.skuArrName].reduce(
(arr, item) => {
return arr.concat(
arr.map(item2 => {
// 利用对象属性的唯一性实现二维数组去重
//console.log(1,that.shopItemInfo,that.getArrayToSting([...item2, item]),item2,item,items);
if (!that.shopItemInfo.hasOwnProperty(that.getArrayToSting([...item2, item]))) {
that.shopItemInfo[that.getArrayToSting([...item2, item])] = items;
}
return [...item2, item];
})
);
},
[[]]
)
);
},
[[]]
);
// console.timeEnd('计算有多小种可选路径需要的时间是');
},
getArrayToSting(arr) {
let str = '';
arr.map((item, index) => {
item = item.replace(/\./g, '。');
if (index == 0) {
str += item;
} else {
str += ',' + item;
}
});
return str;
},
// 检测sku选项是否已全部选完,且有库存
checkSelectComplete(obj = {}) {
let that = this;
let clickTime = new Date().getTime();
if (that.clickTime && clickTime - that.clickTime < 400) {
return false;
}
that.clickTime = clickTime;
let { selectShop, selectNum, stockText, stockName } = that;
if (!selectShop || !selectShop[that.skuIdName]) {
that.toast('请先选择对应规格', 'none');
return false;
}
if (selectNum <= 0) {
that.toast('购买数量必须>0', 'none');
return false;
}
// 判断库存
if (selectNum > selectShop[stockName]) {
that.toast(stockText + '不足', 'none');
return false;
}
if (typeof obj.success == 'function') obj.success(selectShop);
},
// 加入购物车
addCart() {
let that = this;
that.checkSelectComplete({
success: function (selectShop) {
selectShop.buy_num = that.selectNum;
that.$emit('add-cart', selectShop);
that.$emit('cart', selectShop);
// setTimeout(function() {
// that.init();
// }, 300);
}
});
},
// 立即购买
buyNow() {
let that = this;
that.checkSelectComplete({
success: function (selectShop) {
selectShop.buy_num = that.selectNum;
that.$emit('buy-now', selectShop);
that.$emit('buy', selectShop);
}
});
},
// 弹窗
toast(title, icon) {
uni.showToast({
title: title,
icon: icon
});
},
// 获取对象数组中的某一个item,根据指定的键值
getListItem(list, key, value) {
let that = this;
let item;
for (let i in list) {
if (typeof value == 'object') {
if (JSON.stringify(list[i][key]) === JSON.stringify(value)) {
item = list[i];
break;
}
} else {
if (list[i][key] === value) {
item = list[i];
break;
}
}
}
return item;
},
getListIndex(list, key, value) {
let that = this;
let index = -1;
for (let i = 0; i < list.length; i++) {
if (list[i][key] === value) {
index = i;
break;
}
}
return index;
},
// 自动选择sku前提是只有一组sku,默认自动选择最前面的有库存的sku
autoClickSku() {
let that = this;
let { stockName } = that;
let skuList = that.goodsInfo[that.skuListName];
let specListArr = that.goodsInfo[that.specListName];
if (specListArr.length == 1) {
let specList = specListArr[0].list;
for (let i = 0; i < specList.length; i++) {
let sku = that.getListItem(skuList, that.skuArrName, [specList[i].name]);
if (sku && sku[stockName] > 0) {
that.skuClick(specList[i], 0, i);
break;
}
}
}
},
// 主题颜色
themeColorFn(name) {
let that = this;
let { theme, themeColor } = that;
let color = that[name] ? that[name] : themeColor[theme][name];
return color;
},
defaultSelectSku() {
let that = this;
let { defaultSelect } = that;
if (defaultSelect && defaultSelect.sku && defaultSelect.sku.length > 0) {
that.selectSku(defaultSelect);
}
},
/**
* 主动方法 - 设置sku
that.$refs.skuPopup.selectSku({
sku:["红色","256G","公开版"],
num:5
});
*/
selectSku(obj = {}) {
let that = this;
let { sku: skuArr, num: selectNum } = obj;
let specListArr = that.goodsInfo[that.specListName];
if (skuArr && specListArr.length === skuArr.length) {
// 先清空
let skuClickArr = [];
let clickKey = true;
for (let index = 0; index < skuArr.length; index++) {
let skuName = skuArr[index];
let specList = specListArr[index].list;
let index1 = index;
let index2 = that.getListIndex(specList, 'name', skuName);
if (index2 == -1) {
clickKey = false;
break;
}
skuClickArr.push({
spec: specList[index2],
index1: index1,
index2: index2
});
}
if (clickKey) {
that.init(true);
skuClickArr.map(item => {
that.skuClick(item.spec, item.index1, item.index2);
});
}
}
if (selectNum > 0) that.selectNum = selectNum;
},
priceFilter(n = 0) {
let that = this;
if (typeof n == 'string') {
n = parseFloat(n);
}
if (that.amountType === 0) {
return n.toFixed(2);
} else {
return (n / 100).toFixed(2);
}
},
pushGoodsCache(goodsInfo) {
let that = this;
let { goodsIdName } = that;
goodsCache[goodsInfo[goodsIdName]] = goodsInfo;
},
// 用于阻止冒泡
stop() { },
// 图片预览
previewImage() {
let that = this;
let { selectShop, goodsInfo, goodsThumbName } = that;
let src = selectShop.image ? selectShop.image : goodsInfo[goodsThumbName];
if (src) {
uni.previewImage({
urls: [src]
});
}
},
getMaxStock() {
let maxStock = 0;
let that = this;
let { selectShop = {}, goodsInfo = {}, skuListName, stockName } = that;
if (selectShop[stockName]) {
maxStock = selectShop[stockName];
} else {
let skuList = goodsInfo[skuListName];
if (skuList && skuList.length > 0) {
let valueArr = [];
skuList.map((skuItem, index) => {
valueArr.push(skuItem[stockName]);
});
let max = Math.max(...valueArr);
maxStock = max;
}
}
return maxStock;
},
numChange(e) {
this.$emit("num-change", e.value);
}
},
// 计算属性
computed: {
valueCom() {
// #ifndef VUE3
return this.value;
// #endif
// #ifdef VUE3
return this.modelValue;
// #endif
},
// 最大购买数量
maxBuyNumCom() {
let that = this;
let maxStock = that.getMaxStock();
let max = that.maxBuyNum || 100000;
// 最大购买量不能超过当前商品的库存
if (max > maxStock) {
max = maxStock;
}
return max;
},
// 是否是多规格
isManyCom() {
let that = this;
let { goodsInfo, defaultSingleSkuName, specListName } = that;
let isMany = true;
if (
goodsInfo[specListName] &&
goodsInfo[specListName].length === 1 &&
goodsInfo[specListName][0].list.length === 1 &&
goodsInfo[specListName][0].name === defaultSingleSkuName
) {
isMany = false;
}
return isMany;
},
// 默认价格区间计算
priceCom() {
let str = '';
let that = this;
let { selectShop = {}, goodsInfo = {}, skuListName, skuIdName } = that;
if (selectShop[skuIdName]) {
str = that.priceFilter(selectShop.price);
} else {
let skuList = goodsInfo[skuListName];
if (skuList && skuList.length > 0) {
let valueArr = [];
skuList.map((skuItem, index) => {
valueArr.push(skuItem.price);
});
let min = that.priceFilter(Math.min(...valueArr));
let max = that.priceFilter(Math.max(...valueArr));
if (min === max) {
str = min + '';
} else {
str = `${min} - ${max}`;
}
}
}
return str;
},
// 库存显示
stockCom() {
let str = '';
let that = this;
let { selectShop = {}, goodsInfo = {}, skuListName, stockName } = that;
if (selectShop[stockName]) {
str = selectShop[stockName];
} else {
let skuList = goodsInfo[skuListName];
if (skuList && skuList.length > 0) {
let valueArr = [];
skuList.map((skuItem, index) => {
valueArr.push(skuItem[stockName]);
});
let min = Math.min(...valueArr);
let max = Math.max(...valueArr);
if (min === max) {
str = min;
} else {
str = `${min} - ${max}`;
}
}
}
return str;
}
},
watch: {
valueCom(newVal, oldValue) {
let that = this;
if (newVal) {
that.open();
}
},
defaultGoods: {
immediate: true,
handler: function (newVal, oldValue) {
let that = this;
let { goodsIdName } = that;
if (typeof newVal === 'object' && newVal && newVal[goodsIdName] && !goodsCache[newVal[goodsIdName]]) {
that.pushGoodsCache(newVal);
}
}
}
}
};
</script>
<style lang="scss" scoped>
/* sku弹出层 */
.vk-data-goods-sku-popup {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 990;
overflow: hidden;
&.show {
display: block;
.mask {
animation: showPopup 0.2s linear both;
}
.layer {
animation: showLayer 0.2s linear both;
bottom: var(--window-bottom);
}
}
&.hide {
.mask {
animation: hidePopup 0.2s linear both;
}
.layer {
animation: hideLayer 0.2s linear both;
}
}
&.none {
display: none;
}
.mask {
position: fixed;
top: 0;
width: 100%;
height: 100%;
z-index: 1;
background-color: rgba(0, 0, 0, 0.3);
}
.layer {
display: flex;
width: 100%;
// height: 1014rpx;
flex-direction: column;
// min-height: 40vh;
// max-height: 1014rpx;
position: fixed;
z-index: 99;
bottom: 0;
border-radius: 10rpx 10rpx 0 0;
background-color: #fff;
.specification-wrapper {
width: 100%;
padding: 30rpx 25rpx;
box-sizing: border-box;
.specification-wrapper-content {
width: 100%;
max-height: 900rpx;
min-height: 300rpx;
&::-webkit-scrollbar {
/*隐藏滚轮*/
display: none;
}
.specification-header {
width: 100%;
display: flex;
flex-direction: row;
position: relative;
margin-bottom: 40rpx;
.specification-left {
width: 180rpx;
height: 180rpx;
flex: 0 0 180rpx;
.product-img {
width: 180rpx;
height: 180rpx;
}
}
.specification-right {
flex: 1;
padding: 0 35rpx 0 28rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-end;
font-weight: 500;
.price-content {
color: #fe560a;
margin-bottom: 20rpx;
.sign {
font-size: 28rpx;
}
.price {
margin-left: 4rpx;
font-size: 48rpx;
}
.price2 {
margin-left: 4rpx;
font-size: 36rpx;
}
}
.inventory {
font-size: 24rpx;
color: #999999;
margin-bottom: 14rpx;
}
.choose {
font-size: 28rpx;
color: #333333;
}
}
}
.specification-content {
font-weight: 500;
.specification-item {
margin-bottom: 40rpx;
&:last-child {
margin-bottom: 0;
}
.item-title {
margin-bottom: 20rpx;
font-size: 28rpx;
color: #999999;
}
.item-wrapper {
display: flex;
flex-direction: row;
flex-flow: wrap;
.item-content {
display: inline-block;
padding: 10rpx 35rpx;
font-size: 24rpx;
border-radius: 10rpx;
background-color: #ffffff;
color: #333333;
margin-right: 20rpx;
margin-bottom: 16rpx;
border: 1px solid #f4f4f4;
box-sizing: border-box;
&.actived {
border-color: #fe560a;
color: #fe560a;
}
&.noactived {
background-color: #f6f6f6;
border-color: #f6f6f6;
color: #c3c3c3;
}
}
}
}
.number-box-view {
display: flex;
padding-top: 30rpx;
}
}
}
.close {
position: absolute;
top: 30rpx;
right: 25rpx;
width: 50rpx;
height: 50rpx;
text-align: center;
line-height: 50rpx;
.close-item {
width: 50rpx;
height: 50rpx;
}
}
}
.btn-wrapper {
display: flex;
width: 100%;
height: 120rpx;
flex: 0 0 120rpx;
align-items: center;
justify-content: space-between;
padding: 0 26rpx;
box-sizing: border-box;
.layer-btn {
width: 335rpx;
height: 76rpx;
border-radius: 38rpx;
color: #fff;
line-height: 76rpx;
text-align: center;
font-weight: 500;
font-size: 28rpx;
&.add-cart {
background: #ffbe46;
}
&.buy {
background: #fe560a;
}
}
.sure {
width: 698rpx;
height: 68rpx;
border-radius: 38rpx;
color: #fff;
line-height: 68rpx;
text-align: center;
font-weight: 500;
font-size: 28rpx;
background: #fe560a;
}
.sure.add-cart {
background: #ff9402;
}
}
.btn-wrapper.safe-area-inset-bottom {
padding-bottom: 0;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
}
@keyframes showPopup {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes hidePopup {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes showLayer {
0% {
transform: translateY(120%);
}
100% {
transform: translateY(0%);
}
}
@keyframes hideLayer {
0% {
transform: translateY(0);
}
100% {
transform: translateY(120%);
}
}
}
</style>
<!-- 步进器 -->
<template>
<view class="vk-data-input-number-box">
<view class="u-icon-minus" :class="{ 'u-icon-disabled': disabled || inputVal <= min }" :style="{
background: bgColor,
height: inputHeight + 'rpx',
color: color,
fontSize: size + 'rpx',
minHeight: '1.4em'
}" @click="emptyClick" @touchstart.prevent="btnTouchStart('minus')" @touchend.stop.prevent="clearTimer">
<view :style="'font-size:' + (Number(size) + 10) + 'rpx'" class="num-btn">-</view>
</view>
<input v-model="inputVal" :disabled="disabledInput || disabled" :cursor-spacing="getCursorSpacing"
:class="{ 'u-input-disabled': disabled }" class="u-number-input" type="number" :style="{
color: color,
fontSize: size + 'rpx',
background: bgColor,
height: inputHeight + 'rpx',
width: inputWidth + 'rpx'
}" @blur="onBlur" @click="showInput = true" />
<view class="u-icon-plus" :class="{ 'u-icon-disabled': disabled || inputVal >= max }" :style="{
background: bgColor,
height: inputHeight + 'rpx',
color: color,
fontSize: size + 'rpx',
minHeight: '1.4em'
}" @click="emptyClick" @touchstart.prevent="btnTouchStart('plus')" @touchend.stop.prevent="clearTimer">
<view :style="'font-size:' + (Number(size) + 10) + 'rpx'" class="num-btn">+</view>
</view>
</view>
</template>
<script>
/**
* numberBox 步进器(此为uview组件改造)
* @description 该组件一般用于商城购物选择物品数量的场景。注意:该输入框只能输入大于或等于0的整数,不支持小数输入
* @tutorial https://www.uviewui.com/components/numberBox.html
* @property {Number} value 输入框初始值(默认1)
* @property {String} bg-color 输入框和按钮的背景颜色(默认#F2F3F5)
* @property {Number} min 用户可输入的最小值(默认0)
* @property {Number} max 用户可输入的最大值(默认99999)
* @property {Number} step 步长,每次加或减的值(默认1)
* @property {Number} stepFirst 步进值,首次增加或最后减的值(默认step值和一致)
* @property {Boolean} disabled 是否禁用操作,禁用后无法加减或手动修改输入框的值(默认false)
* @property {Boolean} disabled-input 是否禁止输入框手动输入值(默认false)
* @property {Boolean} positive-integer 是否只能输入正整数(默认true)
* @property {String | Number} size 输入框文字和按钮字体大小,单位rpx(默认26)
* @property {String} color 输入框文字和加减按钮图标的颜色(默认#323233)
* @property {String | Number} input-width 输入框宽度,单位rpx(默认80)
* @property {String | Number} input-height 输入框和按钮的高度,单位rpx(默认50)
* @property {String | Number} index 事件回调时用以区分当前发生变化的是哪个输入框
* @property {Boolean} long-press 是否开启长按连续递增或递减(默认true)
* @property {String | Number} press-time 开启长按触发后,每触发一次需要多久,单位ms(默认250)
* @property {String | Number} cursor-spacing 指定光标于键盘的距离,避免键盘遮挡输入框,单位rpx(默认200)
* @event {Function} change 输入框内容发生变化时触发,对象形式
* @event {Function} blur 输入框失去焦点时触发,对象形式
* @event {Function} minus 点击减少按钮时触发(按钮可点击情况下),对象形式
* @event {Function} plus 点击增加按钮时触发(按钮可点击情况下),对象形式
* @example <vk-data-input-number-box :min="1" :max="100"></vk-data-input-number-box>
*/
export default {
name: 'vk-data-input-number-box',
emits: ['update:modelValue', 'input', 'change', 'blur', 'plus', 'minus'],
props: {
// 预显示的数字
value: {
type: Number,
default: 1
},
modelValue: {
type: Number,
default: 1
},
// 背景颜色
bgColor: {
type: String,
default: '#FFFFFF'
},
// 最小值
min: {
type: Number,
default: 0
},
// 最大值
max: {
type: Number,
default: 99999
},
// 步进值,每次加或减的值
step: {
type: Number,
default: 1
},
// 步进值,首次增加或最后减的值
stepFirst: {
type: Number,
default: 0
},
// 是否只能输入 step 的倍数
stepStrictly: {
type: Boolean,
default: false
},
// 是否禁用加减操作
disabled: {
type: Boolean,
default: false
},
// input的字体大小,单位rpx
size: {
type: [Number, String],
default: 26
},
// 加减图标的颜色
color: {
type: String,
default: '#323233'
},
// input宽度,单位rpx
inputWidth: {
type: [Number, String],
default: 80
},
// input高度,单位rpx
inputHeight: {
type: [Number, String],
default: 50
},
// index索引,用于列表中使用,让用户知道是哪个numberbox发生了变化,一般使用for循环出来的index值即可
index: {
type: [Number, String],
default: ''
},
// 是否禁用输入框,与disabled作用于输入框时,为OR的关系,即想要禁用输入框,又可以加减的话
// 设置disabled为false,disabledInput为true即可
disabledInput: {
type: Boolean,
default: false
},
// 输入框于键盘之间的距离
cursorSpacing: {
type: [Number, String],
default: 100
},
// 是否开启长按连续递增或递减
longPress: {
type: Boolean,
default: true
},
// 开启长按触发后,每触发一次需要多久
pressTime: {
type: [Number, String],
default: 250
},
// 是否只能输入大于或等于0的整数(正整数)
positiveInteger: {
type: Boolean,
default: true
}
},
watch: {
valueCom(v1, v2) {
// 只有value的改变是来自外部的时候,才去同步inputVal的值,否则会造成循环错误
if (!this.changeFromInner) {
this.inputVal = v1;
// 因为inputVal变化后,会触发this.handleChange(),在其中changeFromInner会再次被设置为true,
// 造成外面修改值,也导致被认为是内部修改的混乱,这里进行this.$nextTick延时,保证在运行周期的最后处
// 将changeFromInner设置为false
this.$nextTick(function () {
this.changeFromInner = false;
});
}
},
inputVal(v1, v2) {
// 为了让用户能够删除所有输入值,重新输入内容,删除所有值后,内容为空字符串
if (v1 == '') return;
let value = 0;
// 首先判断是否数值,并且在min和max之间,如果不是,使用原来值
let tmp = this.isNumber(v1);
if (tmp && v1 >= this.min && v1 <= this.max) value = v1;
else value = v2;
// 判断是否只能输入大于等于0的整数
if (this.positiveInteger) {
// 小于0,或者带有小数点,
if (v1 < 0 || String(v1).indexOf('.') !== -1) {
value = v2;
// 双向绑定input的值,必须要使用$nextTick修改显示的值
this.$nextTick(() => {
this.inputVal = v2;
});
}
}
// 发出change事件
this.handleChange(value, 'change');
},
min(v1) {
if (v1 !== undefined && v1 != '' && this.valueCom < v1) {
this.$emit('input', v1);
this.$emit('update:modelValue', v1);
}
},
max(v1) {
if (v1 !== undefined && v1 != '' && this.valueCom > v1) {
this.$emit('input', v1);
this.$emit('update:modelValue', v1);
}
}
},
data() {
return {
inputVal: 1, // 输入框中的值,不能直接使用props中的value,因为应该改变props的状态
timer: null, // 用作长按的定时器
changeFromInner: false, // 值发生变化,是来自内部还是外部
innerChangeTimer: null,// 内部定时器
showInput: false,
};
},
created() {
this.inputVal = Number(this.valueCom);
},
computed: {
valueCom() {
// #ifndef VUE3
return this.value;
// #endif
// #ifdef VUE3
return this.modelValue;
// #endif
},
getCursorSpacing() {
// 先将值转为px单位,再转为数值
return Number(uni.upx2px(this.cursorSpacing));
}
},
methods: {
// 空点击事件,主要用于解决PC端H5由于无click事件导致触摸位置不准确的问题
emptyClick() {
},
// 触摸事件开始
btnTouchStart(callback) {
// 先执行一遍方法,否则会造成松开手时,就执行了clearTimer,导致无法实现功能
this[callback]();
// 如果没开启长按功能,直接返回
if (!this.longPress) return;
clearInterval(this.timer); //再次清空定时器,防止重复注册定时器
this.timer = null;
this.timer = setInterval(() => {
// 执行加或减函数
this[callback]();
}, this.pressTime);
},
// 清除定时器
clearTimer() {
this.$nextTick(() => {
clearInterval(this.timer);
this.timer = null;
});
},
// 减
minus() {
this.computeVal('minus');
},
// 加
plus() {
this.computeVal('plus');
},
// 为了保证小数相加减出现精度溢出的问题
calcPlus(num1, num2) {
let baseNum, baseNum1, baseNum2;
try {
baseNum1 = num1.toString().split('.')[1].length;
} catch (e) {
baseNum1 = 0;
}
try {
baseNum2 = num2.toString().split('.')[1].length;
} catch (e) {
baseNum2 = 0;
}
baseNum = Math.pow(10, Math.max(baseNum1, baseNum2));
let precision = baseNum1 >= baseNum2 ? baseNum1 : baseNum2; //精度
return ((num1 * baseNum + num2 * baseNum) / baseNum).toFixed(precision);
},
// 为了保证小数相加减出现精度溢出的问题
calcMinus(num1, num2) {
let baseNum, baseNum1, baseNum2;
try {
baseNum1 = num1.toString().split('.')[1].length;
} catch (e) {
baseNum1 = 0;
}
try {
baseNum2 = num2.toString().split('.')[1].length;
} catch (e) {
baseNum2 = 0;
}
baseNum = Math.pow(10, Math.max(baseNum1, baseNum2));
let precision = baseNum1 >= baseNum2 ? baseNum1 : baseNum2;
return ((num1 * baseNum - num2 * baseNum) / baseNum).toFixed(precision);
},
computeVal(type) {
uni.hideKeyboard();
if (this.disabled) return;
let value = 0;
// 新增stepFirst开始
// 减
if (type === 'minus') {
if (this.stepFirst > 0 && this.inputVal == this.stepFirst) {
value = this.min;
} else {
value = this.calcMinus(this.inputVal, this.step);
}
} else if (type === 'plus') {
if (this.stepFirst > 0 && this.inputVal < this.stepFirst) {
value = this.stepFirst;
} else {
value = this.calcPlus(this.inputVal, this.step);
}
}
if (this.stepStrictly) {
let strictly = value % this.step;
if (strictly > 0) {
value -= strictly;
}
}
if (value > this.max) {
value = this.max;
} else if (value < this.min) {
value = this.min;
}
// 新增stepFirst结束
this.inputVal = value;
this.handleChange(value, type);
},
// 处理用户手动输入的情况
onBlur(event) {
let val = 0;
let value = event.detail.value;
// 如果为非0-9数字组成,或者其第一位数值为0,直接让其等于min值
// 这里不直接判断是否正整数,是因为用户传递的props min值可能为0
if (!/(^\d+$)/.test(value) || value[0] == 0) val = this.min;
val = +value;
// 新增stepFirst开始
if (this.stepFirst > 0 && this.inputVal < this.stepFirst && this.inputVal > 0) {
val = this.stepFirst;
}
// 新增stepFirst结束
if (this.stepStrictly) {
let strictly = val % this.step;
if (strictly > 0) {
val -= strictly;
}
}
if (val > this.max) {
val = this.max;
} else if (val < this.min) {
val = this.min;
}
this.$nextTick(() => {
this.inputVal = val;
});
this.handleChange(val, 'blur');
},
handleChange(value, type) {
if (this.disabled) return;
// 清除定时器,避免造成混乱
if (this.innerChangeTimer) {
clearTimeout(this.innerChangeTimer);
this.innerChangeTimer = null;
}
// 发出input事件,修改通过v-model绑定的值,达到双向绑定的效果
this.changeFromInner = true;
// 一定时间内,清除changeFromInner标记,否则内部值改变后
// 外部通过程序修改value值,将会无效
this.innerChangeTimer = setTimeout(() => {
this.changeFromInner = false;
}, 150);
this.$emit('input', Number(value));
this.$emit('update:modelValue', Number(value));
this.$emit(type, {
// 转为Number类型
value: Number(value),
index: this.index
});
},
/**
* 验证十进制数字
*/
isNumber(value) {
return /^(?:-?\d+|-?\d{1,3}(?:,\d{3})+)?(?:\.\d+)?$/.test(value);
}
}
};
</script>
<style lang="scss" scoped>
.vk-data-input-number-box {
display: inline-flex;
align-items: center;
box-sizing: border-box;
}
.u-number-input {
position: relative;
text-align: center;
padding: 0;
margin: 0rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid #f4f4f4;
border-left: 0;
border-right: 0;
box-sizing: border-box;
}
.u-icon-plus,
.u-icon-minus {
width: 60rpx;
display: flex;
justify-content: center;
align-items: center;
border: 2rpx solid #f4f4f4;
box-sizing: border-box;
}
.u-icon-plus {
border-radius: 0 8rpx 8rpx 0;
}
.u-icon-minus {
border-radius: 8rpx 0 0 8rpx;
}
.u-icon-disabled {
color: #c8c9cc !important;
background-color: #f2f3f5 !important;
}
.u-input-disabled {
color: #c8c9cc !important;
background-color: #f2f3f5 !important;
}
.num-btn {
font-weight: 550;
line-height: 50rpx;
}
</style>
类型声明
import { Component } from '@uni-helper/uni-app-types'
/** SKU 弹出层 */
export type SkuPopup = Component<SkuPopupProps>
/** SKU 弹出层实例 */
export type SkuPopupInstance = InstanceType<SkuPopup>
/** SKU 弹出层属性 */
export type SkuPopupProps = {
/** 双向绑定,true 为打开组件,false 为关闭组件 */
modelValue: boolean
/** 商品信息本地数据源 */
localdata: SkuPopupLocaldata
/** 按钮模式 1:都显示 2:只显示购物车 3:只显示立即购买 */
mode?: 1 | 2 | 3
/** 该商品已抢完时的按钮文字 */
noStockText?: string
/** 库存文字 */
stockText?: string
/** 点击遮罩是否关闭组件 */
maskCloseAble?: boolean
/** 顶部圆角值 */
borderRadius?: string | number
/** 最小购买数量 */
minBuyNum?: number
/** 最大购买数量 */
maxBuyNum?: number
/** 每次点击后的数量 */
stepBuyNum?: number
/** 是否只能输入 step 的倍数 */
stepStrictly?: boolean
/** 是否隐藏库存的显示 */
hideStock?: false
/** 主题风格 */
theme?: 'default' | 'red-black' | 'black-white' | 'coffee' | 'green'
/** 默认金额会除以100(即100=1元),若设置为0,则不会除以100(即1=1元) */
amountType?: 1 | 0
/** 自定义获取商品信息的函数(已知支付宝不支持,支付宝请改用localdata属性) */
customAction?: () => void
/** 是否显示右上角关闭按钮 */
showClose?: boolean
/** 关闭按钮的图片地址 */
closeImage?: string
/** 价格的字体颜色 */
priceColor?: string
/** 立即购买 - 按钮的文字 */
buyNowText?: string
/** 立即购买 - 按钮的字体颜色 */
buyNowColor?: string
/** 立即购买 - 按钮的背景颜色 */
buyNowBackgroundColor?: string
/** 加入购物车 - 按钮的文字 */
addCartText?: string
/** 加入购物车 - 按钮的字体颜色 */
addCartColor?: string
/** 加入购物车 - 按钮的背景颜色 */
addCartBackgroundColor?: string
/** 商品缩略图背景颜色 */
goodsThumbBackgroundColor?: string
/** 样式 - 不可点击时,按钮的样式 */
disableStyle?: object
/** 样式 - 按钮点击时的样式 */
activedStyle?: object
/** 样式 - 按钮常态的样式 */
btnStyle?: object
/** 字段名 - 商品表id的字段名 */
goodsIdName?: string
/** 字段名 - sku表id的字段名 */
skuIdName?: string
/** 字段名 - 商品对应的sku列表的字段名 */
skuListName?: string
/** 字段名 - 商品规格名称的字段名 */
specListName?: string
/** 字段名 - sku库存的字段名 */
stockName?: string
/** 字段名 - sku组合路径的字段名 */
skuArrName?: string
/** 字段名 - 商品缩略图字段名(未选择sku时) */
goodsThumbName?: string
/** 被选中的值 */
selectArr?: string[]
/** 打开弹出层 */
onOpen: () => void
/** 关闭弹出层 */
onClose: () => void
/** 点击加入购物车时(需选择完SKU才会触发)*/
onAddCart: (event: SkuPopupEvent) => void
/** 点击立即购买时(需选择完SKU才会触发)*/
onBuyNow: (event: SkuPopupEvent) => void
}
/** 商品信息本地数据源 */
export type SkuPopupLocaldata = {
/** 商品 ID */
_id: string
/** 商品名称 */
name: string
/** 商品图片 */
goods_thumb: string
/** 商品规格列表 */
spec_list: SkuPopupSpecItem[]
/** 商品SKU列表 */
sku_list: SkuPopupSkuItem[]
}
/** 商品规格名称的集合 */
export type SkuPopupSpecItem = {
/** 规格名称 */
name: string
/** 规格集合 */
list: { name: string }[]
}
/** 商品SKU列表 */
export type SkuPopupSkuItem = {
/** SKU ID */
_id: string
/** 商品 ID */
goods_id: string
/** 商品名称 */
goods_name: string
/** 商品图片 */
image: string
/** SKU 价格 * 100, 注意:需要乘以 100 */
price: number
/** SKU 规格组成, 注意:需要与 spec_list 数组顺序对应 */
sku_name_arr: string[]
/** SKU 库存 */
stock: number
}
/** 当前选择的sku数据 */
export type SkuPopupEvent = SkuPopupSkuItem & {
/** 商品购买数量 */
buy_num: number
}
/** 全局组件类型声明 */
declare module 'vue' {
export interface GlobalComponents {
'vk-data-goods-sku-popup': SkuPopup
}
}
商品详情对应的组件
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { getGoodsDetailApi } from '@/services/goods'
import { computed, ref } from 'vue'
import type { GoodsResult } from '@/types/goods'
import ServicePannel from './components/ServicePannel.vue'
import AddressPanel from './components/AddressPanel.vue'
import GoodsSkeleton from './components/GoodsSkeleton.vue'
import type { SkuPopupInstance, SkuPopupLocaldata } from '@/types/vk-data-goods-sku-popup'
const goodsInfo = ref<GoodsResult>()
// 是否显示SKU组件
const isShowSku = ref(false)
// 商品信息
const localdata = ref({} as SkuPopupLocaldata)
//获取商品详情
const getGoodsInfo = async () => {
const res = await getGoodsDetailApi(query.id)
goodsInfo.value = res.result
// SKU组件所需格式
localdata.value = {
_id: res.result.id,
name: res.result.name,
goods_thumb: res.result.mainPictures[0],
spec_list: res.result.specs.map((v) => ({ name: v.name, list: v.values })),
sku_list: res.result.skus.map((v) => ({
_id: v.id,
goods_id: res.result.id,
goods_name: res.result.name,
image: v.picture,
price: v.price * 100, // 注意:需要乘以 100
stock: v.inventory,
sku_name_arr: v.specs.map((vv) => vv.valueName),
})),
}
}
//启动骨架屏
const isLoading = ref(false)
onLoad(() => {
isLoading.value = true
getGoodsInfo()
isLoading.value = false
})
// uniapp 获取页面参数
const query = defineProps<{
id: string
}>()
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
//高亮的指示点
const activeIndex = ref(0)
//当swiper下标发生变化时出发
const onChange: UniHelper.SwiperOnChange = (ev) => {
// console.log(ev.detail?.current)
activeIndex.value = ev.detail!.current
}
const onPreviewImage = (url: string) => {
uni.previewImage({
urls: goodsInfo.value!.mainPictures,
current: url,
})
}
const popup = ref<{
open: (type?: UniHelper.UniPopupType) => void
close: () => void
}>()
const isAddress = ref<0 | 1>(0)
const openPopup = (isAddr: 0 | 1) => {
isAddress.value = isAddr
popup.value?.open()
}
// 按钮模式
enum SkuMode {
Both = 1,
Cart = 2,
Buy = 3,
}
const mode = ref<SkuMode>(SkuMode.Cart)
// 打开SKU弹窗修改按钮模式
const openSkuPopup = (val: SkuMode) => {
// 显示SKU弹窗
isShowSku.value = true
// 修改按钮模式
mode.value = val
}
// SKU组件实例
const skuPopupRef = ref<SkuPopupInstance>()
// 计算被选中的值
const selectArrText = computed(() => {
return skuPopupRef.value?.selectArr?.join(' ').trim() || '请选择商品规格'
})
</script>
<template>
<!-- SKU弹窗组件 -->
<vk-data-goods-sku-popup v-model="isShowSku" :localdata="localdata" :mode="mode" add-cart-background-color="#FFA868"
buy-now-background-color="#27BA9B" ref="skuPopupRef" :actived-style="{
color: '#27BA9B',
borderColor: '#27BA9B',
backgroundColor: '#E9F8F5',
}" />
<!-- 弹窗测试 -->
<!-- <button @tap="isShowSku = true">打开 SKU 弹窗</button> -->
<GoodsSkeleton v-if="isLoading" />
<scroll-view scroll-y class="viewport" v-else>
<!-- 基本信息 -->
<view class="goods">
<!-- 商品主图 -->
<view class="preview">
<swiper circular @change="onChange">
<swiper-item @click="onPreviewImage(item)" v-for="item in goodsInfo?.mainPictures" :key="item">
<image mode="aspectFill" :src="item" />
</swiper-item>
</swiper>
<view class="indicator">
<text class="current">{{ activeIndex + 1 }}</text>
<text class="split">/</text>
<text class="total">{{ goodsInfo?.mainPictures.length }}</text>
</view>
</view>
<!-- 商品简介 -->
<view class="meta">
<view class="price">
<text class="symbol">¥</text>
<text class="number">{{ goodsInfo?.price }}</text>
</view>
<view class="name ellipsis">{{ goodsInfo?.name }} </view>
<view class="desc"> {{ goodsInfo?.desc }} </view>
</view>
<!-- 操作面板 -->
<view class="action">
<view class="item arrow">
<text class="label">选择</text>
<text class="text ellipsis" @tap="openSkuPopup(SkuMode.Both)"> {{ selectArrText }} </text>
</view>
<view class="item arrow" @tap="openPopup(1)">
<text class="label">送至</text>
<text class="text ellipsis"> 请选择收获地址 </text>
</view>
<view class="item arrow" @tap="openPopup(0)">
<text class="label">服务</text>
<text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
</view>
</view>
</view>
<uni-popup ref="popup" type="bottom" background-color="#fff">
<ServicePannel v-show="isAddress === 0" @close="popup?.close" />
<AddressPanel v-show="isAddress === 1" @close="popup?.close" />
</uni-popup>
<!-- 商品详情 -->
<view class="detail panel">
<view class="title">
<text>详情</text>
</view>
<view class="content">
<view class="properties">
<!-- 属性详情 -->
<view class="item" v-for="item in goodsInfo?.details.properties" :key="item.name">
<text class="label">{{ item.name }}</text>
<text class="value">{{ item.value }}</text>
</view>
</view>
<!-- 图片详情 -->
<image :key="item" v-for="item in goodsInfo?.details.pictures" mode="widthFix" :src="item"></image>
</view>
</view>
<!-- 同类推荐 -->
<view class="similar panel">
<view class="title">
<text>同类推荐</text>
</view>
<view class="content">
<navigator v-for="item in goodsInfo?.similarProducts" :key="item.id" class="goods" hover-class="none"
:url="`/pages/goods/goods?id=${item.id}`">
<image class="image" mode="aspectFill" :src="item.picture"></image>
<view class="name ellipsis">{{ item.name }}</view>
<view class="price">
<text class="symbol">¥</text>
<text class="number">{{ item.price }} </text>
</view>
</navigator>
</view>
</view>
</scroll-view>
<!-- 用户操作 -->
<view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
<view class="icons">
<button class="icons-button"><text class="icon-heart"></text>收藏</button>
<button class="icons-button" open-type="contact">
<text class="icon-handset"></text>客服
</button>
<navigator class="icons-button" url="/pages/cart/cart" open-type="switchTab">
<text class="icon-cart"></text>购物车
</navigator>
</view>
<view class="buttons">
<view class="addcart" @tap="openSkuPopup(SkuMode.Cart)"> 加入购物车 </view>
<view class="buynow" @tap="openSkuPopup(SkuMode.Buy)"> 立即购买 </view>
</view>
</view>
</template>
<style style lang="scss">
page {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.viewport {
background-color: #f4f4f4;
}
.panel {
margin-top: 20rpx;
background-color: #fff;
.title {
display: flex;
justify-content: space-between;
align-items: center;
height: 90rpx;
line-height: 1;
padding: 30rpx 60rpx 30rpx 6rpx;
position: relative;
text {
padding-left: 10rpx;
font-size: 28rpx;
color: #333;
font-weight: 600;
border-left: 4rpx solid #27ba9b;
}
navigator {
font-size: 24rpx;
color: #666;
}
}
}
.arrow {
&::after {
position: absolute;
top: 50%;
right: 30rpx;
content: '\e6c2';
color: #ccc;
font-family: 'erabbit' !important;
font-size: 32rpx;
transform: translateY(-50%);
}
}
/* 商品信息 */
.goods {
background-color: #fff;
.preview {
height: 750rpx;
position: relative;
.image {
width: 750rpx;
height: 750rpx;
}
.indicator {
height: 40rpx;
padding: 0 24rpx;
line-height: 40rpx;
border-radius: 30rpx;
color: #fff;
font-family: Arial, Helvetica, sans-serif;
background-color: rgba(0, 0, 0, 0.3);
position: absolute;
bottom: 30rpx;
right: 30rpx;
.current {
font-size: 26rpx;
}
.split {
font-size: 24rpx;
margin: 0 1rpx 0 2rpx;
}
.total {
font-size: 24rpx;
}
}
}
.meta {
position: relative;
border-bottom: 1rpx solid #eaeaea;
.price {
height: 130rpx;
padding: 25rpx 30rpx 0;
color: #fff;
font-size: 34rpx;
box-sizing: border-box;
background-color: #35c8a9;
}
.number {
font-size: 56rpx;
}
.brand {
width: 160rpx;
height: 80rpx;
overflow: hidden;
position: absolute;
top: 26rpx;
right: 30rpx;
}
.name {
max-height: 88rpx;
line-height: 1.4;
margin: 20rpx;
font-size: 32rpx;
color: #333;
}
.desc {
line-height: 1;
padding: 0 20rpx 30rpx;
font-size: 24rpx;
color: #cf4444;
}
}
.action {
padding-left: 20rpx;
.item {
height: 90rpx;
padding-right: 60rpx;
border-bottom: 1rpx solid #eaeaea;
font-size: 26rpx;
color: #333;
position: relative;
display: flex;
align-items: center;
&:last-child {
border-bottom: 0 none;
}
}
.label {
width: 60rpx;
color: #898b94;
margin: 0 16rpx 0 10rpx;
}
.text {
flex: 1;
-webkit-line-clamp: 1;
}
}
}
/* 商品详情 */
.detail {
padding-left: 20rpx;
.content {
margin-left: -20rpx;
.image {
width: 100%;
}
}
.properties {
padding: 0 20rpx;
margin-bottom: 30rpx;
.item {
display: flex;
line-height: 2;
padding: 10rpx;
font-size: 26rpx;
color: #333;
border-bottom: 1rpx dashed #ccc;
}
.label {
width: 200rpx;
}
.value {
flex: 1;
}
}
}
/* 同类推荐 */
.similar {
.content {
padding: 0 20rpx 200rpx;
background-color: #f4f4f4;
display: flex;
flex-wrap: wrap;
.goods {
width: 340rpx;
padding: 24rpx 20rpx 20rpx;
margin: 20rpx 7rpx;
border-radius: 10rpx;
background-color: #fff;
}
.image {
width: 300rpx;
height: 260rpx;
}
.name {
height: 80rpx;
margin: 10rpx 0;
font-size: 26rpx;
color: #262626;
}
.price {
line-height: 1;
font-size: 20rpx;
color: #cf4444;
}
.number {
font-size: 26rpx;
margin-left: 2rpx;
}
}
navigator {
&:nth-child(even) {
margin-right: 0;
}
}
}
/* 底部工具栏 */
.toolbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
background-color: #fff;
height: 100rpx;
padding: 0 20rpx var(--window-bottom);
border-top: 1rpx solid #eaeaea;
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: content-box;
.buttons {
display: flex;
&>view {
width: 220rpx;
text-align: center;
line-height: 72rpx;
font-size: 26rpx;
color: #fff;
border-radius: 72rpx;
}
.addcart {
background-color: #ffa868;
}
.buynow,
.payment {
background-color: #27ba9b;
margin-left: 20rpx;
}
}
.icons {
padding-right: 10rpx;
display: flex;
align-items: center;
flex: 1;
.icons-button {
flex: 1;
text-align: center;
line-height: 1.4;
padding: 0;
margin: 0;
border-radius: 0;
font-size: 20rpx;
color: #333;
background-color: #fff;
&::after {
border: none;
}
}
text {
display: block;
font-size: 34rpx;
}
}
}
</style>
购物车模块
类型声明
/** 购物车类型 */
export type CartItem = {
/** 商品 ID */
id: string
/** SKU ID */
skuId: string
/** 商品名称 */
name: string
/** 图片 */
picture: string
/** 数量 */
count: number
/** 加入时价格 */
price: number
/** 当前的价格 */
nowPrice: number
/** 库存 */
stock: number
/** 是否选中 */
selected: boolean
/** 属性文字 */
attrsText: string
/** 是否为有效商品 */
isEffective: boolean
}
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
/** 输入框内容发生变化时触发 */
onChange: (event: InputNumberBoxEvent) => void
/** 输入框失去焦点时触发 */
onBlur: (event: InputNumberBoxEvent) => void
/** 点击增加按钮时触发 */
onPlus: (event: InputNumberBoxEvent) => void
/** 点击减少按钮时触发 */
onMinus: (event: InputNumberBoxEvent) => void
}
/** 步进器事件对象 */
export type InputNumberBoxEvent = {
/** 输入框当前值 */
value: number
/** 步进器标识符 */
index: string
}
/** 全局组件类型声明 */
declare module 'vue' {
export interface GlobalComponents {
'vk-data-input-number-box': InputNumberBox
}
}
接口定义
import type { CartItem } from '@/types/car'
import { http } from '@/utils/http'
/**
* 加入购物车
* @param data 请求体参数
*/
export const postMemberCartAPI = (data: { skuId: string; count: number }) => {
return http({
method: 'POST',
url: '/member/cart',
data,
})
}
/**
* 获取购物车列表
*/
export const getMemberCartAPI = () => {
return http<CartItem[]>({
method: 'GET',
url: '/member/cart',
})
}
/**
* 删除/清空购物车单品
* @param data 请求体参数 ids SKUID 集合
*/
export const deleteMemberCartAPI = (data: { ids: string[] }) => {
return http({
method: 'DELETE',
url: '/member/cart',
data,
})
}
/**
* 修改购物车单品
* @param skuId SKUID
* @param data selected 选中状态 count 商品数量
*/
export const putMemberCartBySkuIdAPI = (
skuId: string,
data: { selected?: boolean; count?: number },
) => {
return http({
method: 'PUT',
url: `/member/cart/${skuId}`,
data,
})
}
/**
* 购物车全选/取消全选
* @param data selected 是否选中
*/
export const putMemberCartSelectedAPI = (data: { selected: boolean }) => {
return http({
method: 'PUT',
url: '/member/cart/selected',
data,
})
}
组件代码
<script setup lang="ts">
import {
deleteMemberCartAPI,
getMemberCartAPI,
putMemberCartBySkuIdAPI,
putMemberCartSelectedAPI,
} from '@/services/car'
import { useMemberStore } from '@/stores'
import type { CartItem, InputNumberBoxEvent } from '@/types/car'
import { onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
// 获取会员Store
const memberStore = useMemberStore()
// 获取购物车数据
const cartList = ref<CartItem[]>([])
const getMemberCartData = async () => {
const res = await getMemberCartAPI()
console.log(res.result)
cartList.value = res.result
}
// 初始化调用: 页面显示触发
onShow(() => {
// 用户已登录才允许调用
if (memberStore.profile) {
getMemberCartData()
}
})
// 点击删除按钮
const onDeleteCart = (skuId: string) => {
// 弹窗二次确认
uni.showModal({
content: '是否删除',
success: async (res) => {
if (res.confirm) {
// 后端删除单品
await deleteMemberCartAPI({ ids: [skuId] })
// 重新获取列表
getMemberCartData()
}
},
})
}
// 修改商品数量
const onChangeCount = (ev: InputNumberBoxEvent) => {
putMemberCartBySkuIdAPI(ev.index, { count: ev.value })
}
// 修改选中状态-单品修改
const onChangeSelected = (item: CartItem) => {
// 前端数据更新-是否选中取反
item.selected = !item.selected
// 后端数据更新
putMemberCartBySkuIdAPI(item.skuId, { selected: item.selected })
}
// 计算全选状态
const isSelectedAll = computed(() => {
return cartList.value.length && cartList.value.every((v) => v.selected)
})
// 修改选中状态-全选修改
const onChangeSelectedAll = () => {
// 全选状态取反
const _isSelectedAll = !isSelectedAll.value
// 前端数据更新
cartList.value.forEach((item) => {
item.selected = _isSelectedAll
})
// 后端数据更新
putMemberCartSelectedAPI({ selected: _isSelectedAll })
}
// 计算选中单品列表
const selectedCartList = computed(() => {
return cartList.value.filter((v) => v.selected)
})
// 计算选中总件数
const selectedCartListCount = computed(() => {
return selectedCartList.value.reduce((sum, item) => sum + item.count, 0)
})
// 计算选中总金额
const selectedCartListMoney = computed(() => {
return selectedCartList.value
.reduce((sum, item) => sum + item.count * item.nowPrice, 0)
.toFixed(2)
})
// 结算按钮
const gotoPayment = () => {
if (selectedCartListCount.value === 0) {
return uni.showToast({
icon: 'none',
title: '请选择商品',
})
}
// 跳转到结算页
uni.navigateTo({ url: '/pagesOrder/create/create' })
}
</script>
<template>
<scroll-view scroll-y class="scroll-view">
<!-- 已登录: 显示购物车 -->
<template v-if="memberStore.profile">
<!-- 购物车列表 -->
<view class="cart-list" v-if="true">
<!-- 优惠提示 -->
<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.id" class="cart-swipe">
<!-- 商品信息 -->
<view class="goods">
<!-- 选中状态 -->
<text @tap="onChangeSelected(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 }}</view>
<view class="price">{{ item.price }}</view>
</view>
</navigator>
<!-- 商品数量 -->
<view class="count">
<vk-data-input-number-box v-model="item.count" :min="1" :max="item.stock" :index="item.skuId"
@change="onChangeCount" />
</view>
</view>
<!-- 右侧删除按钮 -->
<template #right>
<view class="cart-swipe-right">
<button class="button delete-button" @tap="onDeleteCart(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 @tap="onChangeSelectedAll" class="all" :class="{ checked: isSelectedAll }">全选</text>
<text class="text">合计:</text>
<text class="amount">{{ selectedCartListMoney }}</text>
<view class="button-grounp">
<view class="button payment-button" :class="{ disabled: selectedCartListCount === 0 }" @tap="gotoPayment">
去结算({{ selectedCartListCount }})
</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>
<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;
display: flex;
justify-content: space-between;
align-items: center;
width: 220rpx;
height: 48rpx;
.text {
height: 100%;
padding: 0 20rpx;
font-size: 32rpx;
color: #444;
}
.input {
height: 100%;
text-align: center;
border-radius: 4rpx;
font-size: 24rpx;
color: #444;
background-color: #f6f6f6;
}
}
}
.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>
<script setup lang="ts">
import CartMain from './components/CarMain.vue'
</script>
<template>
<CartMain />
</template>
<style lang="scss">
page {
height: 100%;
}
</style>
<script setup lang="ts">
import CartMain from './components/CartMain.vue'
</script>
<template>
<CartMain />
</template>
<style lang="scss">
page {
height: 100%;
}
</style>