2025-10-21

商品详情加入购物车与创建订单
支付页面70%
This commit is contained in:
2025-10-21 15:25:55 +08:00
parent 091b655a0f
commit db65f73deb
7 changed files with 884 additions and 16 deletions

View File

@@ -7,7 +7,11 @@ const baseUrl = "http://192.168.0.26:3000"
export const mallAPI = {
getMallList: (params) => http.get(baseUrl + '/api/products', params),
getCategory: () => http.get(baseUrl + '/api/category'),
getMallDetail: (id) => http.get(baseUrl + '/api/products/' + id)
getMallDetail: (id) => http.get(baseUrl + '/api/products/' + id),
getRecommended: (id) => http.get(baseUrl + `/api/products/${id}/recommended`), // 推荐商品
addCart: (data) => http.post(baseUrl +"/api/cart/add", data), // 添加购物车
createOrder: (data) => http.post(baseUrl + `/api/orders/create-from-cart`,data), // 创建订单
getOrder: (orderId) => http.get(baseUrl + `/api/orders/pending-payment/${orderId}`), // 获取订单
}
export default {

View File

@@ -53,6 +53,38 @@
"__platform__" : [ "android" ]
}
}
},
"icons" : {
"android" : {
"hdpi" : "unpackage/res/icons/72x72.png",
"xhdpi" : "unpackage/res/icons/96x96.png",
"xxhdpi" : "unpackage/res/icons/144x144.png",
"xxxhdpi" : "unpackage/res/icons/192x192.png"
},
"ios" : {
"appstore" : "unpackage/res/icons/1024x1024.png",
"ipad" : {
"app" : "unpackage/res/icons/76x76.png",
"app@2x" : "unpackage/res/icons/152x152.png",
"notification" : "unpackage/res/icons/20x20.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"proapp@2x" : "unpackage/res/icons/167x167.png",
"settings" : "unpackage/res/icons/29x29.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"spotlight" : "unpackage/res/icons/40x40.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png"
},
"iphone" : {
"app@2x" : "unpackage/res/icons/120x120.png",
"app@3x" : "unpackage/res/icons/180x180.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"notification@3x" : "unpackage/res/icons/60x60.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"settings@3x" : "unpackage/res/icons/87x87.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png",
"spotlight@3x" : "unpackage/res/icons/120x120.png"
}
}
}
}
},

View File

@@ -136,13 +136,20 @@
"navigationBarTitleText": "商品详情",
"navigationStyle": "custom"
}
},
{
"path" : "pages/home/pay",
"style" :
{
"navigationBarTitleText" : "确认订单",
"backgroundColor": "#E4ECFF"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
"backgroundColor": "#f5f8ff"
},
"uniIdRouter": {},
"tabBar": {

View File

@@ -203,7 +203,6 @@
// 加载数据
const loadMallData = () => {
console.log(111);
if (loadStatus.value == 'nomore') return
mallAPI.getMallList(params.value).then((res) => {
mallList.value = mallList.value.concat(res.data.products)

View File

@@ -7,15 +7,101 @@
</template>
</u-navbar>
<scroll-view scroll-y="true" :style="'height:'+scrollHeight+'px'">
111
<view class="img-banner">
<u-swiper height="400" img-mode="aspectFit" mode="none" bg-color="transparent" :list="dataInfo.banner"
@change="handleChangeBanner" :current="currentBanner"></u-swiper>
</view>
<scroll-view scroll-x="true" class="img-sub-banner">
<image v-for="(item,index) in dataInfo.banner" :src="item" class="img-item"
:class="{active: currentBanner==index}" mode="aspectFit" @click="handleChangeBanner(index)">
</image>
</scroll-view>
<view class="mall-info">
<view class="name">
{{dataInfo.name}}
</view>
<view class="description">
{{dataInfo.description}}
</view>
<view class="price">
<view>
<view class="mall-price">
<image src="/static/icon/rongdou.png" class="icon" mode=""></image>
{{dataInfo.rongdou_price}}
</view>
<view class="mall-price">
<u-icon name="integral"></u-icon>
{{dataInfo.points_price}}
</view>
<view v-if="dataInfo.discount" class="discount-info">
<u-tag type="error" text="1折优惠" />
</view>
</view>
<view>
<view>已售{{dataInfo.sales}}</view>
<view style="color: red;">剩余{{dataInfo.stock}}</view>
</view>
</view>
<view class="rating">
评分<u-rate :disabled="true" :count="5" v-model="dataInfo.rating" active-color="#f7ba2a"></u-rate>
</view>
<view class="detail">
<h3>具体描述</h3>
<view v-html="dataInfo.details"></view>
</view>
<view class="recommend">
<h3>推荐商品</h3>
<view class="mall-list">
<u-waterfall v-model="recommendedList" ref="mallListRef">
<template v-slot:left="{leftList}">
<view class="mall-item u-m-r-10" v-for="(item, index) in leftList" :key="index"
@click="handleCheck(item)">
<u-lazy-load threshold="-450" border-radius="10" :image="getImageUrl(item.image)"
:index="index"></u-lazy-load>
<view class="mall-title u-m-l-5 u-m-r-5">
{{item.name}}
</view>
<view class="mall-price u-m-l-5 u-m-r-5">
<image src="/static/icon/rongdou.png" class="icon" mode=""></image>
{{item.price}}
</view>
<view class="mall-price u-m-l-5 u-m-r-5">
<u-icon name="integral"></u-icon>
{{item.points}}
</view>
</view>
</template>
<template v-slot:right="{rightList}">
<view class="mall-item u-m-l-10" v-for="(item, index) in rightList" :key="index"
@click="handleCheck(item)">
<u-lazy-load threshold="-450" border-radius="10" :image="getImageUrl(item.image)"
:index="index"></u-lazy-load>
<view class="mall-title u-m-l-5 u-m-r-5">
{{item.name}}
</view>
<view class="mall-price u-m-l-5 u-m-r-5">
<image src="/static/icon/rongdou.png" class="icon" mode=""></image>
{{item.price}}
</view>
<view class="mall-price u-m-l-5 u-m-r-5">
<u-icon name="integral"></u-icon>
{{item.points}}
</view>
</view>
</template>
</u-waterfall>
</view>
</view>
</view>
</scroll-view>
<view class="bottom-view" id="bottomViewId">
<view class="icon-btn">
<view class="item">
<!-- <view class="item">
<u-image width="100%" :fade="false" src="/static/mall/Home.png" mode="widthFix"></u-image>
店铺
</view>
</view> -->
<view class="item">
<u-image width="100%" :fade="false" src="/static/mall/Twitch.png" mode="widthFix"></u-image>
客服
@@ -26,19 +112,95 @@
</view>
</view>
<view class="text-btn">
<u-button class="add-car common" :hair-line="false" hover-class="none">加入购物车</u-button>
<u-button class="buy common" :hair-line="false" hover-class="none">领券购买</u-button>
<u-button class="add-car common" :hair-line="false" hover-class="none"
@click="handleBtn(0)">加入购物车</u-button>
<u-button class="buy common" :hair-line="false" hover-class="none" @click="handleBtn(1)">领券购买</u-button>
</view>
</view>
<u-popup v-model="showSure" mode="bottom" length="70%" :closeable="true" blur="90%">
<scroll-view scroll-y="true" style="height: 100%;">
<view class="sure-popup">
<view class="title">{{popTitle}}</view>
<view class="address">
<view class="text">
<image style="width: 40rpx;height: 40rpx;" src="/static/icon/Map pin2.png" mode=""></image>
<view class="u-m-l-10">张三 | </view>
<view class="u-m-l-10">浙江省 宁波市 海曙区</view>
</view>
<view class="right-icon">
<image style="width: 100%;height: 100%;" src="/static/icon/Chevron right Menu.png" mode="">
</image>
</view>
</view>
<view class="count-select u-p-l-10 u-p-r-10">
<view class="pre-view">
<u-image :src="getImageUrl(dataInfo.image_url)" height="100%" width="100%">
<template v-slot:error>
<view style="font-size: 24rpx;">暂无图片</view>
</template>
</u-image>
</view>
<view class="text">
<view>
实付<image src="/static/icon/rongdou.png" class="icon" mode=""></image>
{{dataInfo.price * order.count}}
<view>
<u-icon name="integral"></u-icon>
{{dataInfo.points * order.count}}
</view>
</view>
<view>
<u-number-box v-model="order.count" :max="dataInfo.stock" :min="1"></u-number-box>
</view>
</view>
</view>
<view class="spec-option" v-for="item in specNames">
<view class="title">
{{item + "("+specOptions.get(item).size+")"}}
</view>
<view class="option-list">
<view class="option-item" v-for="key in specOptions.get(item).keys()" :key="key"
:class="{active: isChose(key), unactive: !canChose(key)}"
@click="handleChangeSpec(key, item)">
{{specOptions.get(item).get(key)}}
</view>
</view>
</view>
<view class="spec-option">
<view class="title">
订单备注
</view>
<view class="">
<u-input type="textarea" :border="true" :height="100" v-model="order.orderNote"></u-input>
</view>
</view>
<view class="spec-option">
<u-button @click="handleSubmit">{{popTitle}}</u-button>
</view>
</view>
</scroll-view>
</u-popup>
<u-toast ref="msgRef" />
</view>
</template>
<script setup lang="ts">
import { onMounted, ref, getCurrentInstance } from 'vue';
<script setup>
import {
onMounted,
ref,
getCurrentInstance
} from 'vue';
import {
onLoad
} from '@dcloudio/uni-app';
import { mallAPI } from '../../api/mall';
import {
mallAPI
} from '../../api/mall';
import {
getImageUrl
} from '../../util/common.js';
const instance = getCurrentInstance();
const scrollHeight = ref(0)
@@ -46,21 +208,255 @@
uni.getSystemInfo({
success(res) {
let screenHeight = res.screenHeight
uni.createSelectorQuery().in(instance.proxy).select("#uNavbarId").boundingClientRect((data : any) => {
uni.createSelectorQuery().in(instance.proxy).select("#uNavbarId").boundingClientRect((
data) => {
scrollHeight.value = screenHeight - data.height
}).exec()
uni.createSelectorQuery().in(instance.proxy).select("#bottomViewId").boundingClientRect((data : any) => {
uni.createSelectorQuery().in(instance.proxy).select("#bottomViewId").boundingClientRect((
data) => {
scrollHeight.value = scrollHeight.value - data.height
}).exec()
}
})
}
const currentBanner = ref(0)
const handleChangeBanner = (index) => {
currentBanner.value = index
}
const handleCheck = (item) => {
uni.redirectTo({
url: '/pages/home/mallDetail?id=' + item.id
})
}
const msgRef = ref()
// 切换 加入购物车/确认购买
const showSure = ref(false)
const popTitle = ref('')
const flag = ref(0)
const handleBtn = (sign) => {
if (sign == 0) {
popTitle.value = '加入购物车'
flag.value = 0
} else if (sign == 1) {
popTitle.value = '确认购买'
flag.value = 1
}
showSure.value = true
}
const order = ref({
count: 1
})
const choseKeys = ref([])
const handleChangeSpec = (key, allKey) => {
if (!canChose(key)) return
if (choseKeys.value.includes(key)) {
// 取消选中
choseKeys.value = choseKeys.value.filter(item => item != key)
} else {
// console.log(choseKeys.value);
// console.log(specOptions.value.get(allKey));
// 清除选项
const keysArray = Array.from(specOptions.value.get(allKey).keys())
choseKeys.value = choseKeys.value.filter(item => {
if (keysArray.includes(item)) return false
return true
})
// 选中
choseKeys.value.push(key)
}
}
// 判断当前key是否可选
const canChose = (key) => {
// console.log("当前规格ID:", key);
// console.log("规格组合列表:", activeKeys.value);
// console.log("已选择的规格ID列表:", choseKeys.value);
// console.log("规格选项:", specOptions.value);
// 如果已经选中,肯定可选(因为可以取消选中)
if (isChose(key)) {
return true;
}
// 构建临时选择的key列表
let tempChoseKeys = [...choseKeys.value];
// 找到当前key所属的规格类别
let specCategory = '';
for (const [category, options] of specOptions.value.entries()) {
if (options.has(key)) {
specCategory = category;
break;
}
}
// 如果找到了所属规格类别,需要先移除同类别下已选的其他选项
if (specCategory) {
const sameCategoryKeys = Array.from(specOptions.value.get(specCategory).keys());
tempChoseKeys = tempChoseKeys.filter(item => !sameCategoryKeys.includes(item));
}
// 添加当前要判断的key
tempChoseKeys.push(key);
// 检查是否存在包含所有临时选择key的有效规格组合
const hasValidCombination = activeKeys.value.some(combination => {
return arrayContainsAll(combination, tempChoseKeys);
});
return hasValidCombination;
}
// arr1是否包含arr2
const arrayContainsAll = (arr1, arr2) => {
return arr2.every(item => arr1.includes(item));
}
// 是否被选中
const isChose = (key) => {
return choseKeys.value.includes(key)
}
const handleSubmit = () => {
// 匹配规格
let specification = dataInfo.value.specifications.filter(item => {
let group = item.combination_key.split('-').map(Number)
if (arraysEqualUnordered(group, choseKeys.value)) return true
return false
})
let specificationId = null
if (specification.length != 0) {
specificationId = specification[0].id
} else {
msgRef.value.show({
title: '请选择规格',
type: 'warning'
})
return
}
// 立即购买
const cartItem = {
productId: dataInfo.value.id,
quantity: order.value.count,
specificationId: specificationId,
points: dataInfo.value.points,
name: dataInfo.value.name,
image: dataInfo.value.image,
stock: dataInfo.value.stock
}
if (flag.value == 0) {
// 加入购物车
mallAPI.addCart(cartItem).then(res => {
if (res.success) {
msgRef.value.show({
title: '已加入购物车',
type: 'error'
})
} else {
msgRef.value.show({
title: res.data.message || '添加到购物车失败',
type: 'error'
})
}
}).finally(() => {
order.value.count = 1
order.value.orderNote = ''
choseKeys.value = []
showSure.value = false
})
} else if (flag.value == 1) {
mallAPI.addCart(cartItem).then(res => {
if (res.success) {
const cartItemId = res.data?.cart_item_id || res.data?.id || res.data
?.cartItemId || res.id
if (!cartItemId) {
msgRef.value.show({
title: '无法获取购物车项ID',
type: 'error'
})
} else {
mallAPI.createOrder({
cart_item_ids: [cartItemId]
}).then(response => {
if (response.success) {
// 进入支付页面
uni.navigateTo({
url: '/pages/home/pay?preOrderId=' + response.data
.preOrderId
})
} else {
msgRef.value.show({
title: '操作失败',
type: 'error'
})
}
})
}
} else {
msgRef.value.show({
title: res.data.message || '添加到购物车失败',
type: 'error'
})
}
})
}
}
// 判断两个数组值是否相等
const arraysEqualUnordered = (arr1, arr2) => {
if (arr1.length !== arr2.length) return false;
const sorted1 = [...arr1].sort();
const sorted2 = [...arr2].sort();
return sorted1.every((item, index) => item === sorted2[index]);
}
const dataId = ref()
const dataInfo = ref({})
const recommendedList = ref([])
const specNames = ref(new Set())
const specOptions = ref(new Map())
const activeKeys = ref([])
const loadData = () => {
mallAPI.getMallDetail(dataId.value).then(res => {
console.log(res);
dataInfo.value = res.data.product
dataInfo.value.banner = []
if (dataInfo.value.images.length != 0) {
dataInfo.value.images.forEach(item => {
dataInfo.value.banner.push(getImageUrl(item))
})
}
loadSpec()
})
mallAPI.getRecommended(dataId.value).then(res => {
recommendedList.value = res.data.products
})
}
const loadSpec = () => {
dataInfo.value.specifications.forEach(specification => {
activeKeys.value.push(specification.combination_key)
specification.spec_details.forEach(detail => {
specNames.value.add(detail.spec_display_name)
let data = specOptions.value.get(detail.spec_display_name)
if (data == undefined) {
data = new Map()
data.set(detail.id, detail.display_value)
specOptions.value.set(detail.spec_display_name, data)
} else {
data.set(detail.id, detail.display_value)
specOptions.value.set(detail.spec_display_name, data)
}
})
})
}
@@ -80,13 +476,138 @@
height: 100vh;
background: linear-gradient(180deg, #2F75F9 0%, #F0F3FF 34.13%);
.collection {
width: 48rpx;
height: 48rpx;
margin-right: 24rpx;
}
.img-sub-banner {
padding: 12rpx 0;
background: #fff;
white-space: nowrap;
.img-item {
border-radius: 20rpx;
width: 128rpx;
height: 128rpx;
margin: 0 4rpx;
}
.active {
border: 1px solid #189eff;
}
}
.mall-info {
padding: 20rpx 34rpx;
.name {
font-weight: 650;
font-size: 20px;
leading-trim: NONE;
line-height: 46rpx;
letter-spacing: 0%;
}
.price {
display: flex;
justify-content: space-between;
align-items: center;
.mall-price {
font-size: 30rpx;
color: #305DEF;
margin-top: 10rpx;
.icon {
height: 30rpx;
width: 30rpx;
}
}
}
.detail {
margin-top: 20rpx;
border: 1rpx solid #fff;
padding: 10rpx 20rpx;
box-shadow: 1px 1px 2px 2px rgba(0, 0, 255, 0.2);
}
.recommend {
margin-top: 20rpx;
.mall-list {
margin-top: 20rpx;
.mall-item {
border-radius: 16rpx;
background: #F0F5FF;
position: relative;
margin-top: 20rpx;
box-shadow: 0px 4px 4px 0px #00000040;
padding-bottom: 10rpx;
.mall-title {
font-size: 30rpx;
margin-top: 10rpx;
color: $u-main-color;
}
.mall-price {
font-size: 30rpx;
color: $u-type-error;
margin-top: 10rpx;
.icon {
height: 30rpx;
width: 30rpx;
}
}
.mall-tag {
display: flex;
margin-top: 5px;
.mall-tag-owner {
background-color: $u-type-error;
color: #FFFFFF;
display: flex;
align-items: center;
padding: 4rpx 14rpx;
border-radius: 50rpx;
font-size: 20rpx;
line-height: 1;
}
.mall-tag-text {
margin-right: 10px;
border: 1px solid $u-type-primary;
color: $u-type-primary;
border-radius: 50rpx;
line-height: 1;
padding: 4rpx 14rpx;
display: flex;
align-items: center;
border-radius: 50rpx;
font-size: 20rpx;
}
}
.mall-shop {
font-size: 22rpx;
color: $u-tips-color;
margin-top: 5px;
}
}
}
}
}
.bottom-view {
position: absolute;
bottom: 0;
@@ -158,4 +679,93 @@
}
}
}
.sure-popup {
background: #F5F8FF;
padding-bottom: 20rpx;
.title {
font-weight: 274;
font-style: Light;
font-size: 32rpx;
leading-trim: NONE;
line-height: 48rpx;
letter-spacing: 0%;
text-align: center;
}
.address {
display: flex;
align-items: center;
margin-top: 30rpx;
padding: 20rpx 10rpx;
justify-content: space-between;
.text {
display: flex;
align-items: center;
}
.right-icon {
width: 40rpx;
height: 40rpx;
}
}
.count-select {
display: flex;
margin-top: 10rpx;
.pre-view {
width: 160rpx;
height: 160rpx;
}
.text {
margin-left: 20rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.icon {
height: 30rpx;
width: 30rpx;
}
}
.spec-option {
margin: 20rpx 0;
padding: 0 20rpx;
.title {
font-weight: 700;
font-style: Bold;
font-size: 32rpx;
leading-trim: NONE;
line-height: 48rpx;
letter-spacing: 0%;
text-align: left;
}
.option-list {
display: flex;
.option-item {
background: #f2f3f5;
padding: 10rpx 20rpx;
margin: 20rpx 20rpx 20rpx 0;
}
.active {
border: 1rpx solid #ff6b35;
color: #ff6b35;
}
.unactive {
background: #D9D9D9;
}
}
}
}
</style>

216
pages/home/pay.vue Normal file
View File

@@ -0,0 +1,216 @@
<template>
<view class="pay-container">
<scroll-view scroll-y="true" style="height: 100%;">
<view class="scroll-container">
<view class="box-bg">
<view class="address">
<view class="text">
<image style="width: 40rpx;height: 40rpx;" src="/static/icon/Map pin2.png" mode=""></image>
<view class="u-m-l-10">张三 | </view>
<view class="u-m-l-10">浙江省 宁波市 海曙区</view>
</view>
<view class="right-icon">
<image style="width: 100%;height: 100%;" src="/static/icon/Chevron right Menu.png" mode="">
</image>
</view>
</view>
<view class="count-select u-p-l-10 u-p-r-10" v-for="item in dataInfo.items">
<view class="pre-view">
<u-image :src="getImageUrl(item.image_url)" height="100%" width="100%">
<template v-slot:error>
<view style="font-size: 24rpx;">暂无图片</view>
</template>
</u-image>
</view>
<view class="text">
{{item.product_name}}
融豆{{item.rongdou_price}}
积分{{item.points_price}}
数量X{{item.quantity}}
</view>
</view>
</view>
<view class="box-bg description u-m-t-20">
<view class="item">
<view class="title">
订单编号
</view>
<view class="value">
{{dataInfo.order_no}}
</view>
</view>
<view class="item">
<view class="title">
创建时间
</view>
<view class="value">
{{dataInfo.created_at}}
</view>
</view>
</view>
<view class="box-bg pay-method u-m-t-20">
<view class="title">
支付方式
</view>
<view class="item">
<view class="title">
融豆
</view>
<view class="value">
<u-checkbox v-model="checked" shape="circle" active-color="#305def"></u-checkbox>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="box-bottom">
<view class="text">
实际支付1231
</view>
<view class="btn">
<u-button>确认支付</u-button>
</view>
</view>
<u-toast ref="msgRef" />
</view>
</template>
<script setup>
import {
onMounted,
ref
} from 'vue';
import {
onLoad
} from '@dcloudio/uni-app'
import {
mallAPI
} from '../../api/mall';
import {
getImageUrl
} from '../../util/common.js'
const checked = ref(false)
const dataInfo = ref({})
const loadData = () => {
mallAPI.getOrder(preOrderId.value).then(res => {
dataInfo.value = res.data
})
}
const preOrderId = ref()
onLoad((val) => {
preOrderId.value = val.preOrderId
})
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.pay-container {
width: 100%;
height: 100%;
background: var(--Color, #E4ECFF);
.scroll-container {
.box-bg {
background: #F5F8FF;
}
.address {
display: flex;
align-items: center;
padding: 20rpx 10rpx;
justify-content: space-between;
.text {
display: flex;
align-items: center;
}
.right-icon {
width: 40rpx;
height: 40rpx;
}
}
.count-select {
display: flex;
margin-top: 10rpx;
.pre-view {
width: 160rpx;
height: 160rpx;
}
.text {
margin-left: 20rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.icon {
height: 30rpx;
width: 30rpx;
}
}
.description {
padding: 30rpx 40rpx;
.item {
display: flex;
justify-content: space-between;
margin-bottom: 30rpx;
}
}
.pay-method {
padding: 30rpx 40rpx;
.title {
font-weight: 400;
font-style: Regular;
font-size: 28rpx;
leading-trim: NONE;
line-height: 48rpx;
}
.item {
display: flex;
justify-content: space-between;
margin-bottom: 30rpx;
}
}
}
.box-bottom {
width: 100%;
padding: 20rpx 40rpx;
display: flex;
justify-content: space-between;
align-items: center;
position: absolute;
bottom: 0;
background: #F5F8FF;
.text {}
.btn {}
}
}
</style>

BIN
static/icon/Map pin2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB