feat: 商品详情交互调整

This commit is contained in:
duanshuwen
2025-08-03 11:45:42 +08:00
parent fef285f98d
commit 42c5354978
28 changed files with 3144 additions and 1 deletions

View File

@@ -0,0 +1,234 @@
# GoodConfirm 商品确认组件
基于 uni-popup 弹出层的商品确认组件,提供优雅的商品购买确认界面。
## 功能特性
- 🎨 **现代化设计** - 采用底部弹出设计,符合移动端交互习惯
- 📱 **响应式布局** - 完美适配各种屏幕尺寸
- 🛒 **商品信息展示** - 支持商品图片、标题、价格、标签展示
- 🔢 **数量选择** - 提供加减按钮和手动输入两种方式
- 💰 **实时计算** - 自动计算并显示总价
-**性能优化** - 基于 Vue 3 Composition API性能卓越
- 🎭 **动画效果** - 流畅的弹出和交互动画
- 🔧 **高度可配置** - 支持自定义商品数据和事件处理
## 基础用法
### 默认使用
```vue
<template>
<view>
<button @click="showConfirm">显示确认弹窗</button>
<GoodConfirm
ref="confirmRef"
:goodsData="goodsData"
@confirm="handleConfirm"
@close="handleClose"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import GoodConfirm from '@/pages/goods/components/GoodConfirm/index.vue'
const confirmRef = ref(null)
const goodsData = ref({
commodityName: '【成人票】云从朵花温泉门票',
price: 399,
timeTag: '随时可退',
commodityPhotoList: [
{
photoUrl: '/static/test/mk_img_1.png'
}
]
})
const showConfirm = () => {
confirmRef.value?.showPopup()
}
const handleConfirm = (orderData) => {
console.log('确认订单:', orderData)
// 处理订单确认逻辑
}
const handleClose = () => {
console.log('关闭弹窗')
}
</script>
```
### 自定义商品数据
```vue
<script setup>
const customGoodsData = ref({
commodityName: '【亲子套票】海洋世界一日游',
price: 268,
timeTag: '限时优惠',
commodityPhotoList: [
{
photoUrl: '/path/to/custom/image.jpg'
}
]
})
</script>
```
## API 文档
### Props
| 属性名 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| goodsData | Object | {} | 商品数据对象 |
#### goodsData 对象结构
```typescript
interface GoodsData {
commodityName?: string; // 商品名称
price?: number; // 商品价格
timeTag?: string; // 时间标签(如:随时可退)
commodityPhotoList?: Array<{ // 商品图片列表
photoUrl: string; // 图片URL
}>;
}
```
### Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| confirm | orderData | 确认购买时触发 |
| close | - | 关闭弹窗时触发 |
#### confirm 事件参数
```typescript
interface OrderData {
goodsData: GoodsData; // 商品数据
quantity: number; // 购买数量
totalPrice: string; // 总价(字符串格式)
}
```
### Methods
| 方法名 | 参数 | 说明 |
|--------|------|------|
| showPopup | - | 显示弹窗 |
| closePopup | - | 关闭弹窗 |
## 样式定制
组件使用 SCSS 编写样式,支持以下自定义变量:
```scss
// 主色调
$primary-color: #ff6b35;
$primary-gradient: linear-gradient(135deg, #ff6b35 0%, #ff8f65 100%);
// 文字颜色
$text-primary: #333;
$text-secondary: #666;
// 背景颜色
$bg-white: #fff;
$bg-gray: #f8f9fa;
$border-color: #f5f5f5;
// 圆角
$border-radius: 8px;
$border-radius-large: 20px;
```
## 高级用法
### 响应式数据绑定
```vue
<script setup>
import { computed } from 'vue'
const goodsData = computed(() => ({
commodityName: store.currentGoods.name,
price: store.currentGoods.price,
timeTag: store.currentGoods.refundPolicy,
commodityPhotoList: store.currentGoods.images
}))
</script>
```
### 订单处理集成
```vue
<script setup>
import { createOrder } from '@/api/order'
const handleConfirm = async (orderData) => {
try {
uni.showLoading({ title: '处理中...' })
const result = await createOrder({
commodityId: goodsData.value.id,
quantity: orderData.quantity,
totalAmount: orderData.totalPrice
})
uni.hideLoading()
uni.showToast({ title: '订单创建成功', icon: 'success' })
// 跳转到支付页面
uni.navigateTo({
url: `/pages/payment/index?orderId=${result.orderId}`
})
} catch (error) {
uni.hideLoading()
uni.showToast({ title: '订单创建失败', icon: 'error' })
}
}
</script>
```
## 注意事项
1. **依赖要求**:组件依赖 `uni-popup``uni-icons`,请确保项目中已安装相关依赖
2. **图片资源**:请确保商品图片路径正确,建议使用绝对路径或网络图片
3. **数量限制**:组件默认最小购买数量为 1可根据业务需求调整
4. **价格格式**:价格支持数字类型,组件内部会自动处理格式化
5. **事件处理**:建议在 `confirm` 事件中添加适当的错误处理和用户反馈
## 更新日志
### v1.0.0 (2024-01-XX)
- ✨ 初始版本发布
- 🎨 基于 uni-popup 的底部弹出设计
- 🛒 完整的商品信息展示功能
- 🔢 数量选择和总价计算
- 📱 响应式移动端适配
- 🎭 流畅的动画效果
- 📚 完整的文档和示例
## 技术栈
- **框架**: Vue 3 + Composition API
- **UI组件**: uni-app + uni-ui
- **样式**: SCSS
- **构建工具**: Vite
## 浏览器支持
- iOS Safari 10+
- Android Chrome 50+
- 微信小程序
- 支付宝小程序
- H5 现代浏览器
## 许可证
MIT License

View File

@@ -0,0 +1,191 @@
<template>
<view class="demo-container">
<TopNavBar title="商品确认组件演示" :fixed="true" />
<view class="content-wrapper">
<view class="demo-section">
<view class="section-title">基础用法</view>
<view class="demo-item">
<button class="demo-btn" @click="showBasicDemo">显示基础确认弹窗</button>
</view>
</view>
<view class="demo-section">
<view class="section-title">自定义商品数据</view>
<view class="demo-item">
<button class="demo-btn" @click="showCustomDemo">显示自定义商品弹窗</button>
</view>
</view>
<view class="demo-section">
<view class="section-title">功能特性</view>
<view class="feature-list">
<view class="feature-item"> 基于 uni-popup 弹出层组件</view>
<view class="feature-item"> 商品信息展示图片标题价格标签</view>
<view class="feature-item"> 数量选择控制加减按钮手动输入</view>
<view class="feature-item"> 实时总价计算</view>
<view class="feature-item"> 确认购买和关闭事件</view>
<view class="feature-item"> 响应式设计适配移动端</view>
<view class="feature-item"> 优雅的动画效果</view>
</view>
</view>
</view>
<!-- 基础演示弹窗 -->
<GoodConfirm
ref="basicDemoRef"
:goodsData="basicGoodsData"
@confirm="handleBasicConfirm"
@close="handleBasicClose"
/>
<!-- 自定义演示弹窗 -->
<GoodConfirm
ref="customDemoRef"
:goodsData="customGoodsData"
@confirm="handleCustomConfirm"
@close="handleCustomClose"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import TopNavBar from '@/components/TopNavBar/index.vue'
import GoodConfirm from './index.vue'
// 引用
const basicDemoRef = ref(null)
const customDemoRef = ref(null)
// 基础商品数据
const basicGoodsData = ref({
commodityName: '【成人票】云从朵花温泉门票',
price: 399,
timeTag: '随时可退',
commodityPhotoList: [
{
photoUrl: '/static/test/mk_img_1.png'
}
]
})
// 自定义商品数据
const customGoodsData = ref({
commodityName: '【亲子套票】海洋世界一日游',
price: 268,
timeTag: '限时优惠',
commodityPhotoList: [
{
photoUrl: '/static/test/mk_img_1.png'
}
]
})
// 方法定义
const showBasicDemo = () => {
basicDemoRef.value?.showPopup()
}
const showCustomDemo = () => {
customDemoRef.value?.showPopup()
}
const handleBasicConfirm = (orderData) => {
console.log('基础演示确认订单:', orderData)
uni.showModal({
title: '订单确认',
content: `商品:${orderData.goodsData.commodityName}\n数量${orderData.quantity}\n总价¥${orderData.totalPrice}`,
showCancel: false
})
}
const handleBasicClose = () => {
console.log('基础演示关闭弹窗')
}
const handleCustomConfirm = (orderData) => {
console.log('自定义演示确认订单:', orderData)
uni.showModal({
title: '订单确认成功',
content: `商品:${orderData.goodsData.commodityName}\n数量${orderData.quantity}\n总价¥${orderData.totalPrice}`,
showCancel: false
})
}
const handleCustomClose = () => {
console.log('自定义演示关闭弹窗')
}
</script>
<style scoped lang="scss">
.demo-container {
min-height: 100vh;
background: #f5f5f5;
}
.content-wrapper {
padding: 100px 20px 20px;
}
.demo-section {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #ff6b35;
}
.demo-item {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.demo-btn {
width: 100%;
height: 48px;
background: linear-gradient(135deg, #ff6b35 0%, #ff8f65 100%);
color: #fff;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.3);
&:active {
transform: translateY(1px);
box-shadow: 0 1px 4px rgba(255, 107, 53, 0.3);
}
&::after {
border: none;
}
}
.feature-list {
.feature-item {
padding: 8px 0;
font-size: 14px;
color: #666;
line-height: 20px;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,151 @@
<template>
<uni-popup ref="popup" type="bottom">
<view class="good-confirm-container">
<!-- 头部标题栏 -->
<view class="header">
<view class="header-title">确认订单</view>
<view class="close-btn" @click="closePopup">
<uni-icons type="closeempty" size="20" color="#666"></uni-icons>
</view>
</view>
<!-- 商品信息区域 -->
<view class="goods-info">
<view class="goods-image">
<image
:src="
goodsData.commodityPhotoList?.[0]?.photoUrl ||
'/static/test/mk_img_1.png'
"
mode="aspectFill"
/>
</view>
<view class="goods-details">
<view class="goods-title">{{
goodsData.commodityName || "商品名称"
}}</view>
<view class="goods-price">
<text class="currency">¥</text>
<text class="price">{{ goodsData.price || 399 }}</text>
</view>
<view class="goods-tag" v-if="goodsData.timeTag">
{{ goodsData.timeTag }}
</view>
</view>
</view>
<!-- 数量选择区域 -->
<view class="quantity-section">
<view class="quantity-label">购买数量</view>
<view class="quantity-control">
<view
class="quantity-btn"
:class="{ disabled: quantity <= 1 }"
@click="decreaseQuantity"
>
<uni-icons type="minus" size="16" color="#666"></uni-icons>
</view>
<view class="quantity-input">
<input
type="number"
v-model="quantity"
@input="handleQuantityInput"
:disabled="false"
/>
</view>
<view class="quantity-btn" @click="increaseQuantity">
<uni-icons type="plus" size="16" color="#666"></uni-icons>
</view>
</view>
</view>
<!-- 总价区域 -->
<view class="total-section">
<view class="total-label">合计</view>
<view class="total-price">
<text class="currency">¥</text>
<text class="price">{{ totalPrice }}</text>
</view>
</view>
<!-- 底部按钮区域 -->
<view class="footer">
<button class="confirm-btn" @click="confirmOrder">确认购买</button>
</view>
</view>
</uni-popup>
</template>
<script setup>
import { ref, computed, defineProps, defineEmits } from "vue";
// Props定义
const props = defineProps({
goodsData: {
type: Object,
default: () => ({}),
},
});
// Emits定义
const emits = defineEmits(["confirm", "close"]);
// 响应式数据
const popup = ref(null);
const quantity = ref(1);
// 计算属性
const totalPrice = computed(() => {
const price = props.goodsData.price || 399;
return (price * quantity.value).toFixed(0);
});
// 方法定义
const showPopup = () => {
popup.value?.open();
};
const closePopup = () => {
popup.value?.close();
emits("close");
};
const increaseQuantity = () => {
quantity.value++;
};
const decreaseQuantity = () => {
if (quantity.value > 1) {
quantity.value--;
}
};
const handleQuantityInput = (e) => {
const value = parseInt(e.detail.value);
if (value && value > 0) {
quantity.value = value;
} else {
quantity.value = 1;
}
};
const confirmOrder = () => {
const orderData = {
goodsData: props.goodsData,
quantity: quantity.value,
totalPrice: totalPrice.value,
};
emits("confirm", orderData);
closePopup();
};
// 暴露方法给父组件
defineExpose({
showPopup,
closePopup,
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,239 @@
.good-confirm-container {
background: #fff;
border-radius: 20px 20px 0 0;
padding: 0;
max-height: 80vh;
overflow: hidden;
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
border-bottom: 1px solid #f5f5f5;
position: relative;
.header-title {
font-size: 18px;
font-weight: 600;
color: #333;
flex: 1;
text-align: center;
}
.close-btn {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background: #f8f8f8;
transition: background 0.2s;
&:active {
background: #e8e8e8;
}
}
}
.goods-info {
display: flex;
padding: 20px;
gap: 12px;
border-bottom: 1px solid #f5f5f5;
.goods-image {
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.goods-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
.goods-title {
font-size: 16px;
font-weight: 500;
color: #333;
line-height: 22px;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.goods-price {
display: flex;
align-items: baseline;
margin-bottom: 8px;
.currency {
font-size: 14px;
color: #ff6b35;
font-weight: 500;
}
.price {
font-size: 20px;
color: #ff6b35;
font-weight: 600;
margin-left: 2px;
}
}
.goods-tag {
display: inline-block;
padding: 2px 8px;
background: #fff2e8;
color: #ff6b35;
font-size: 12px;
border-radius: 4px;
border: 1px solid #ffdbcc;
align-self: flex-start;
}
}
}
.quantity-section {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #f5f5f5;
.quantity-label {
font-size: 16px;
color: #333;
font-weight: 500;
}
.quantity-control {
display: flex;
align-items: center;
gap: 0;
border: 1px solid #e8e8e8;
border-radius: 6px;
overflow: hidden;
.quantity-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #f8f8f8;
transition: background 0.2s;
&:active:not(.disabled) {
background: #e8e8e8;
}
&.disabled {
opacity: 0.4;
pointer-events: none;
}
}
.quantity-input {
width: 60px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-left: 1px solid #e8e8e8;
border-right: 1px solid #e8e8e8;
input {
width: 100%;
height: 100%;
text-align: center;
border: none;
outline: none;
font-size: 16px;
color: #333;
background: transparent;
}
}
}
}
.total-section {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: #f8f9fa;
.total-label {
font-size: 16px;
color: #333;
font-weight: 500;
}
.total-price {
display: flex;
align-items: baseline;
.currency {
font-size: 16px;
color: #ff6b35;
font-weight: 600;
}
.price {
font-size: 24px;
color: #ff6b35;
font-weight: 700;
margin-left: 2px;
}
}
}
.footer {
padding: 20px;
background: #fff;
.confirm-btn {
width: 100%;
height: 48px;
background: linear-gradient(135deg, #ff6b35 0%, #ff8f65 100%);
color: #fff;
border: none;
border-radius: 24px;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
&:active {
transform: translateY(1px);
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.3);
}
&::after {
border: none;
}
}
}
}

View File

@@ -12,8 +12,21 @@
<ModuleTitle title="购买须知" />
<zero-markdown-view :markdown="goodsData.commodityTip" :fontSize="14" />
<!-- 立即抢购 -->
<view class="footer">
<button class="buy-button" @click="showConfirmPopup">立即抢购</button>
</view>
</view>
</view>
<!-- 商品确认弹窗 -->
<GoodConfirm
ref="goodConfirmRef"
:goodsData="goodsData"
@confirm="handleConfirmOrder"
@close="handleCloseConfirm"
/>
</view>
</template>
@@ -25,8 +38,11 @@ import TopNavBar from "@/components/TopNavBar/index.vue";
import ImageSwiper from "@/components/ImageSwiper/index.vue";
import GoodInfo from "./components/GoodInfo/index.vue";
import ModuleTitle from "@/components/ModuleTitle/index.vue";
import GoodConfirm from "./components/GoodConfirm/index.vue";
const goodsData = ref({});
const goodConfirmRef = ref(null);
// 获取商品详情数据
const goodsInfo = async (params) => {
const res = await goodsDetail(params);
@@ -34,6 +50,29 @@ const goodsInfo = async (params) => {
goodsData.value = res.data;
};
// 显示确认弹窗
const showConfirmPopup = () => {
goodConfirmRef.value?.showPopup();
};
// 处理确认订单
const handleConfirmOrder = (orderData) => {
console.log("确认订单:", orderData);
uni.showToast({
title: "订单确认成功",
icon: "success",
});
// 这里可以跳转到订单页面或支付页面
// uni.navigateTo({
// url: '/pages/order/detail?orderId=' + orderData.orderId
// });
};
// 处理关闭弹窗
const handleCloseConfirm = () => {
console.log("关闭确认弹窗");
};
onLoad(({ commodityId = "1950766939442774018" }) => {
goodsInfo({ commodityId });
});

View File

@@ -1,3 +1,6 @@
$button-color: #00a6ff;
$button-hover-color: darken($button-color, 8%);
.goods-container {
min-height: 100vh;
background-color: #fff;
@@ -5,6 +8,8 @@
.content-wrapper {
// 为固定导航栏预留空间
padding-top: calc(var(--status-bar-height, 44px) + 68px);
// 为安全区预留空间
padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 100px);
}
.goods-content {
@@ -15,4 +20,77 @@
margin-top: -30px;
z-index: 1;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #fff;
padding-top: 12px;
padding-left: 12px;
padding-right: 12px;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
// 为安全区预留空间
padding-bottom: var(--safe-area-inset-bottom, 0);
.buy-button {
width: 100%;
background: linear-gradient(179deg, #00a6ff 0%, #0256ff 100%);
color: #fff;
border: none;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50px;
height: 42px;
font-size: 14px;
font-weight: 500;
margin-top: 12px;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
letter-spacing: 0.5px;
// 按钮波纹效果
&::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
&:hover {
background: linear-gradient(
135deg,
$button-hover-color 0%,
darken($button-hover-color, 5%) 100%
);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba($button-color, 0.4);
&::before {
width: 300px;
height: 300px;
}
}
&:active {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba($button-color, 0.3);
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba($button-color, 0.3);
}
}
}
}