chore: remove unused GoodConfirm component and update UIs
- Remove all associated files of the GoodConfirm component: image assets, styles, demo page, README documentation, and main component file - Replace uni-icons "right" icon with van-icon "arrow-right" in booking index page - Refactor ScenicImageCard component: update layout, add gradient caption overlay, adjust expand button positioning, and remove legacy SCSS import
This commit is contained in:
@@ -36,7 +36,7 @@
|
||||
<span class="text-[12px] text-ink-600 leading-[18px]">取消政策及说明</span>
|
||||
<div class="flex items-center">
|
||||
<span class="text-[12px] text-[#2D91FF] leading-[16px]" @click="refundVisible = true">取消政策</span>
|
||||
<uni-icons type="right" size="15" color="#99A0AE" />
|
||||
<van-icon name="arrow-right" size="15" color="#99A0AE" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
# GoodConfirm 商品确认组件
|
||||
|
||||
基于 uni-popup 弹出层的商品确认组件,提供优雅的商品购买确认界面。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🎨 **现代化设计** - 采用底部弹出设计,符合移动端交互习惯
|
||||
- 📱 **响应式布局** - 完美适配各种屏幕尺寸
|
||||
- 🛒 **商品信息展示** - 支持商品图片、标题、价格、标签展示
|
||||
- 🔢 **数量选择** - 提供加减按钮和手动输入两种方式
|
||||
- 👥 **动态表单管理** - 根据数量自动添加/删除游客信息表单
|
||||
- 📝 **表单数据绑定** - 支持姓名和手机号的双向绑定
|
||||
- 📱 **横向滚动支持** - 游客信息区域支持横向滚动浏览多个表单
|
||||
- 🗑️ **删除表单支持** - 支持删除游客信息表单,自动同步数量和列表长度
|
||||
- 💰 **实时计算** - 自动计算并显示总价
|
||||
- ⚡ **性能优化** - 基于 Vue 3 Composition API,性能卓越
|
||||
- 🎭 **动画效果** - 流畅的弹出和交互动画
|
||||
- 🔧 **高度可配置** - 支持自定义商品数据和事件处理
|
||||
|
||||
## 基础用法
|
||||
|
||||
### 默认使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<button @click="showConfirm">显示确认弹窗</button>
|
||||
|
||||
<GoodConfirm
|
||||
ref="confirmRef"
|
||||
:goodsData="goodsData"
|
||||
@confirm="handleConfirm"
|
||||
@close="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</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);
|
||||
// orderData 包含:
|
||||
// {
|
||||
// goodsData: 商品数据,
|
||||
// quantity: 购买数量,
|
||||
// totalPrice: 总价,
|
||||
// userFormList: 游客信息列表 [{ name: '', phone: '' }, ...]
|
||||
// }
|
||||
};
|
||||
|
||||
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; // 总价(字符串格式)
|
||||
userFormList: Array<{
|
||||
// 游客信息列表
|
||||
name: string; // 游客姓名
|
||||
phone: 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` 事件中添加适当的错误处理和用户反馈
|
||||
6. **表单管理**:游客信息表单会根据购买数量自动调整,无需手动管理
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.4.2 (2024-12-19)
|
||||
|
||||
**修复Stepper组件响应性问题**
|
||||
|
||||
- 🐛 修复Stepper组件不响应外部modelValue变化的问题
|
||||
- 🔄 添加watch监听器,确保Stepper组件能实时同步外部值变化
|
||||
- ✨ 完善删除表单卡片时Stepper显示值的自动更新
|
||||
- 🎯 提升组件间数据同步的准确性和实时性
|
||||
|
||||
### v1.4.1 (2024-12-19)
|
||||
|
||||
**修复quantity更新问题**
|
||||
|
||||
- 🐛 修复删除表单时quantity值未正确更新的问题
|
||||
- 🔧 优化watch监听器逻辑,添加删除标志位防止冲突
|
||||
- 🎯 改进deleteUserForm方法,确保数据同步准确
|
||||
- 🔍 增强demo页面调试信息,便于测试验证
|
||||
|
||||
### v1.4.0
|
||||
|
||||
- 🗑️ **新增删除功能** - 支持点击删除图标删除游客信息表单
|
||||
- 🔄 **智能数量同步** - 删除表单时自动同步quantity数量和userFormList长度
|
||||
- 🛡️ **最小限制保护** - 确保至少保留一位游客信息,防止全部删除
|
||||
- 🎯 **动态图标控制** - 只有多于一个表单时才显示删除图标
|
||||
- 📱 **用户体验优化** - 删除操作提供友好的提示信息
|
||||
|
||||
### v1.3.0
|
||||
|
||||
- 🚫 **防换行优化** - 确保FormCard组件在任何情况下都不会换行显示
|
||||
- 📐 **布局增强** - 添加flex-shrink: 0确保表单卡片保持固定宽度
|
||||
- 🎯 **滚动优化** - 改进横向滚动体验,支持触摸滚动
|
||||
- 🧪 **测试改进** - demo页面新增多人数测试按钮,便于测试横向滚动效果
|
||||
|
||||
### v1.2.0
|
||||
|
||||
- 📱 **新增横向滚动** - 游客信息区域支持横向滚动浏览多个表单
|
||||
- 🎨 **优化布局设计** - 表单卡片采用固定宽度,提升视觉体验
|
||||
- 🔧 **改进滚动体验** - 隐藏滚动条,提供更清爽的界面
|
||||
- 📐 **响应式优化** - 确保在不同屏幕尺寸下的良好表现
|
||||
|
||||
### v1.1.0
|
||||
|
||||
- 🎉 **新增动态表单管理** - 根据购买数量自动添加/删除游客信息表单
|
||||
- 📝 **新增表单数据绑定** - 支持游客姓名和手机号的双向绑定
|
||||
- 🔄 **优化数据结构** - 确认订单时返回完整的用户信息列表
|
||||
- 🎯 **改进用户体验** - 表单项数量与购买数量实时同步
|
||||
- 🐛 **修复已知问题** - 优化组件初始化和数据更新逻辑
|
||||
|
||||
### 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
|
||||
@@ -1,216 +0,0 @@
|
||||
<template>
|
||||
<div class="demo-container">
|
||||
<div class="demo-header">
|
||||
<span class="demo-title">GoodConfirm 组件演示</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<div class="section-title">基础用法</div>
|
||||
<button class="demo-btn" @click="showConfirm">显示商品确认弹窗</button>
|
||||
<button class="demo-btn" @click="setQuantity(5)" style="margin-left: 12px">
|
||||
设置5人测试横向滚动
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<div class="section-title">当前状态</div>
|
||||
<div class="status-info">
|
||||
<text>Stepper数量: {{ quantity }}</text>
|
||||
<text>表单项数量: {{ userFormCount }}</text>
|
||||
<text>总价: ¥{{ totalPrice }}</text>
|
||||
<span class="debug-info">实时quantity值: {{ quantity }}</span>
|
||||
<span class="feature-highlight">✨ 支持横向滚动浏览多个游客信息</span>
|
||||
<span class="feature-highlight">🗑️ 支持删除游客信息(至少保留一位)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section" v-if="lastOrderData">
|
||||
<div class="section-title">最后提交的数据</div>
|
||||
<div class="order-data">
|
||||
<span>商品: {{ lastOrderData.goodsData.commodityName }}</span>
|
||||
<span>数量: {{ lastOrderData.quantity }}</span>
|
||||
<span>总价: ¥{{ lastOrderData.totalPrice }}</span>
|
||||
<span>用户信息:</span>
|
||||
<div class="user-list">
|
||||
<div v-for="(user, index) in lastOrderData.userFormList" :key="index" class="user-item">
|
||||
<span>游客{{ index + 1 }}: {{ user.name || "未填写" }} -
|
||||
{{ user.phone || "未填写" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GoodConfirm ref="confirmRef" :goodsData="goodsData" @confirm="handleConfirm" @close="handleClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import GoodConfirm from "./index.vue";
|
||||
|
||||
const confirmRef = ref(null);
|
||||
const quantity = ref(1);
|
||||
const userFormCount = ref(1);
|
||||
const lastOrderData = ref(null);
|
||||
|
||||
const goodsData = ref({
|
||||
commodityName: "云从朵花温泉票(成人票)",
|
||||
price: 70,
|
||||
timeTag: "条件退款",
|
||||
commodityPhotoList: [
|
||||
{
|
||||
photoUrl: "/static/test/mk_img_1.png",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const totalPrice = computed(() => {
|
||||
return (goodsData.value.price * quantity.value).toFixed(0);
|
||||
});
|
||||
|
||||
const showConfirm = () => {
|
||||
confirmRef.value?.showPopup();
|
||||
};
|
||||
|
||||
const handleConfirm = (orderData) => {
|
||||
console.log("确认订单:", orderData);
|
||||
lastOrderData.value = orderData;
|
||||
quantity.value = orderData.quantity;
|
||||
userFormCount.value = orderData.userFormList.length;
|
||||
|
||||
uni.showToast({
|
||||
title: "订单确认成功",
|
||||
icon: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
console.log("弹窗关闭");
|
||||
};
|
||||
|
||||
const setQuantity = (num) => {
|
||||
quantity.value = num;
|
||||
userFormCount.value = num;
|
||||
// 如果弹窗已打开,需要通过组件内部的quantity来更新
|
||||
if (confirmRef.value) {
|
||||
// 这里可以通过ref访问组件内部状态,但由于组件封装,我们通过重新打开来演示
|
||||
uni.showToast({
|
||||
title: `已设置${num}人,请重新打开弹窗查看效果`,
|
||||
icon: "none",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.demo-container {
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.demo-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.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: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-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;
|
||||
}
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
|
||||
&.feature-highlight {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8f65 100%);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.debug-info {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
font-weight: bold;
|
||||
border: 1px solid #91d5ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.order-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.user-list {
|
||||
margin-top: 8px;
|
||||
padding-left: 12px;
|
||||
|
||||
.user-item {
|
||||
margin-bottom: 4px;
|
||||
|
||||
text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
@@ -1,369 +0,0 @@
|
||||
<template>
|
||||
<uni-popup
|
||||
ref="popup"
|
||||
type="bottom"
|
||||
background-color="#fff"
|
||||
border-radius="12px 12px 0 0"
|
||||
mask-background-color="rgba(0,0,0,0.5)"
|
||||
:safe-area="false"
|
||||
>
|
||||
<div class="good-container">
|
||||
<!-- 头部区域 -->
|
||||
<div class="header">
|
||||
<div class="header-title">填写信息</div>
|
||||
<div class="close-btn" @click="closePopup">
|
||||
<uni-icons type="closeempty" size="24" color="#333" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 商品信息区域 -->
|
||||
<scroll-div class="good-content" :scroll-y="true" :show-scrollbar="false">
|
||||
<div class="wrapper">
|
||||
<div class="good-info-wrapper">
|
||||
<!-- 轮播图区域 -->
|
||||
<imgSwiper
|
||||
:images="goodsData.commodityPhotoList"
|
||||
:height="130"
|
||||
:border-radius="0"
|
||||
:showThumbnails="false"
|
||||
/>
|
||||
|
||||
<!-- 商品信息区域 -->
|
||||
<div class="goods-info">
|
||||
<div class="goods-details">
|
||||
<div class="goods-title">
|
||||
{{ goodsData.commodityName || "商品名称" }}
|
||||
</div>
|
||||
<div class="goods-price">
|
||||
<span class="currency">¥</span>
|
||||
<template v-if="goodsData.orderType == 0">
|
||||
<span class="price">
|
||||
{{ goodsData.calculatedTotalPrice || 0 }}
|
||||
</span>
|
||||
<span class="price-desc">
|
||||
({{ goodsData.startDate }}至{{ goodsData.endDate }}) 共{{
|
||||
goodsData.totalDays
|
||||
}}晚
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="price">
|
||||
{{ goodsData.specificationPrice || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="goods-service-list"
|
||||
v-if="
|
||||
goodsData.commodityServiceList &&
|
||||
goodsData.commodityServiceList.length
|
||||
"
|
||||
>
|
||||
<div class="service-title">包含服务</div>
|
||||
<div
|
||||
class="goods-service-item"
|
||||
v-for="item in goodsData.commodityServiceList"
|
||||
:key="item.serviceTitle"
|
||||
>
|
||||
<span class="service-label">{{ item.serviceTitle }}</span>
|
||||
<span class="service-value">{{ item.serviceAmount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数量选择区域 -->
|
||||
<div class="quantity-section">
|
||||
<ModuleTitle :title="sectionTitle" />
|
||||
|
||||
<Stepper v-model="quantity" />
|
||||
</div>
|
||||
|
||||
<!-- 游客信息区域 -->
|
||||
<scroll-div
|
||||
class="user-form-list"
|
||||
:scroll-x="true"
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<FormCard
|
||||
v-for="(item, index) in userFormList"
|
||||
:title="userCardTitle(index)"
|
||||
:form="item"
|
||||
:showDeleteIcon="userFormList.length > 1"
|
||||
:key="index"
|
||||
@update:visitorName="
|
||||
(value) => updateUserForm(index, 'visitorName', value)
|
||||
"
|
||||
@update:contactPhone="
|
||||
(value) => updateUserForm(index, 'contactPhone', value)
|
||||
"
|
||||
@delete="() => deleteUserForm(index)"
|
||||
/>
|
||||
</scroll-div>
|
||||
|
||||
<!-- 总价区域 -->
|
||||
<SumCard
|
||||
:referencePrice="
|
||||
goodsData.orderType == 0
|
||||
? goodsData.calculatedTotalPrice
|
||||
: goodsData.specificationPrice
|
||||
"
|
||||
:discount="totalPrice"
|
||||
/>
|
||||
</div>
|
||||
</scroll-div>
|
||||
|
||||
<!-- 底部按钮区域 -->
|
||||
<div class="footer">
|
||||
<div class="left">
|
||||
<span class="total-count">共{{ quantity }}间,合计:</span>
|
||||
<span class="total-price">{{ totalPrice }}</span>
|
||||
</div>
|
||||
|
||||
<div class="confirm-btn" @click="confirmOrder">立即支付</div>
|
||||
</div>
|
||||
</div>
|
||||
</uni-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, nextTick } from "vue";
|
||||
import ImageSwiper from "@/components/ImageSwiper/index.vue";
|
||||
import ModuleTitle from "@/components/ModuleTitle/index.vue";
|
||||
import Stepper from "@/components/Stepper/index.vue";
|
||||
import FormCard from "@/components/FormCard/index.vue";
|
||||
import SumCard from "@/components/SumCard/index.vue";
|
||||
|
||||
// 工具函数
|
||||
const showToast = (title, icon = "none", duration = 2000) => {
|
||||
uni.showToast({ title, icon, duration });
|
||||
};
|
||||
|
||||
const isValidUserForm = (user) => {
|
||||
return (
|
||||
user &&
|
||||
typeof user.visitorName === "string" &&
|
||||
user.visitorName.trim() !== "" &&
|
||||
typeof user.contactPhone === "string" &&
|
||||
user.contactPhone.trim() !== ""
|
||||
);
|
||||
};
|
||||
|
||||
// 常量定义
|
||||
const COMMODITY_TYPES = {
|
||||
HOTEL: 0,
|
||||
TICKET: 1,
|
||||
DINING: 2,
|
||||
};
|
||||
|
||||
const DEFAULT_PRICE = 399;
|
||||
const MIN_USER_COUNT = 1;
|
||||
|
||||
// Props定义
|
||||
const props = defineProps({
|
||||
goodsData: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
orderType: "0",
|
||||
specificationPrice: "",
|
||||
commodityName: "",
|
||||
commodityPhotoList: [],
|
||||
commodityServiceList: [],
|
||||
}),
|
||||
validator: (value) => {
|
||||
return value && typeof value === "object";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Emits定义
|
||||
const emits = defineEmits(["confirm", "close"]);
|
||||
|
||||
// 工具函数
|
||||
const createEmptyUserForm = () => {
|
||||
return { visitorName: "", contactPhone: "" };
|
||||
};
|
||||
|
||||
// 响应式数据
|
||||
const popup = ref(null);
|
||||
const quantity = ref(MIN_USER_COUNT);
|
||||
const userFormList = ref([createEmptyUserForm()]); // 初始化一个表单项
|
||||
const isDeleting = ref(false); // 标志位,防止删除时watch冲突
|
||||
|
||||
// 计算属性
|
||||
const totalPrice = computed(() => {
|
||||
const price =
|
||||
props.goodsData?.orderType == 0
|
||||
? props.goodsData?.calculatedTotalPrice
|
||||
: props.goodsData?.specificationPrice || 0;
|
||||
return (price * quantity.value).toFixed(0);
|
||||
});
|
||||
|
||||
const isHotelType = computed(() => {
|
||||
return props.goodsData?.orderType == 0;
|
||||
});
|
||||
|
||||
const sectionTitle = computed(() => {
|
||||
return isHotelType.value ? "订房信息" : "游客信息";
|
||||
});
|
||||
|
||||
const userCardTitle = computed(() => {
|
||||
return (index) =>
|
||||
isHotelType.value ? `房间${index + 1}` : `游客${index + 1}`;
|
||||
});
|
||||
|
||||
// 监听 quantity 变化,动态调整 userFormList
|
||||
watch(
|
||||
quantity,
|
||||
async (newQuantity) => {
|
||||
// 如果正在执行删除操作,跳过watch逻辑
|
||||
if (isDeleting.value) {
|
||||
isDeleting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保数量不小于最小值
|
||||
if (newQuantity < MIN_USER_COUNT) {
|
||||
quantity.value = MIN_USER_COUNT;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLength = userFormList.value.length;
|
||||
|
||||
if (newQuantity > currentLength) {
|
||||
// 数量增加,添加新的表单项
|
||||
const newForms = Array.from({ length: newQuantity - currentLength }, () =>
|
||||
createEmptyUserForm(),
|
||||
);
|
||||
userFormList.value.push(...newForms);
|
||||
} else if (newQuantity < currentLength) {
|
||||
// 数量减少,删除多余的表单项
|
||||
userFormList.value.splice(newQuantity);
|
||||
}
|
||||
|
||||
// 等待DOM更新完成
|
||||
await nextTick();
|
||||
},
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
// 方法定义
|
||||
const showPopup = () => {
|
||||
popup.value?.open();
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
popup.value?.close();
|
||||
emits("close");
|
||||
};
|
||||
|
||||
const updateUserForm = (index, field, value) => {
|
||||
if (!userFormList.value[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["visitorName", "contactPhone"].includes(field)) {
|
||||
return;
|
||||
}
|
||||
|
||||
userFormList.value[index][field] = value?.toString().trim() || "";
|
||||
};
|
||||
|
||||
const deleteUserForm = (index) => {
|
||||
// 参数验证
|
||||
if (
|
||||
typeof index !== "number" ||
|
||||
index < 0 ||
|
||||
index >= userFormList.value.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保至少保留一个表单项
|
||||
if (userFormList.value.length <= MIN_USER_COUNT) {
|
||||
const message = isHotelType.value
|
||||
? "至少需要一个房间信息"
|
||||
: "至少需要一位游客信息";
|
||||
showToast(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置删除标志位,防止watch监听器干扰
|
||||
isDeleting.value = true;
|
||||
|
||||
// 删除指定索引的表单项
|
||||
userFormList.value.splice(index, 1);
|
||||
|
||||
// 同步更新quantity
|
||||
quantity.value = userFormList.value.length;
|
||||
};
|
||||
|
||||
const validateUserForms = () => {
|
||||
const invalidUsers = userFormList.value.filter((user, index) => {
|
||||
return !isValidUserForm(user);
|
||||
});
|
||||
|
||||
if (invalidUsers.length > 0) {
|
||||
const message = isHotelType.value
|
||||
? "请填写完整的房客信息"
|
||||
: "请填写完整的游客信息";
|
||||
showToast(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const confirmOrder = () => {
|
||||
try {
|
||||
// 校验用户信息是否填写完整
|
||||
if (!validateUserForms()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建订单数据
|
||||
const orderData = {
|
||||
goodsData: props.goodsData,
|
||||
quantity: quantity.value,
|
||||
totalPrice: parseFloat(totalPrice.value),
|
||||
userFormList: userFormList.value.map((user) => ({
|
||||
visitorName: user.visitorName.trim(),
|
||||
contactPhone: user.contactPhone.trim(),
|
||||
})),
|
||||
commodityType: props.goodsData?.orderType,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 触发确认事件
|
||||
emits("confirm", orderData);
|
||||
|
||||
// 关闭弹窗
|
||||
closePopup();
|
||||
} catch (error) {
|
||||
showToast("订单处理失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
// 初始化用户表单列表
|
||||
if (userFormList.value.length === 0) {
|
||||
userFormList.value.push(createEmptyUserForm());
|
||||
}
|
||||
});
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
showPopup,
|
||||
closePopup,
|
||||
resetForm: () => {
|
||||
userFormList.value = [createEmptyUserForm()];
|
||||
quantity.value = MIN_USER_COUNT;
|
||||
},
|
||||
validateForms: validateUserForms,
|
||||
getUserFormList: () => userFormList.value,
|
||||
getTotalPrice: () => totalPrice.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
@@ -1,208 +0,0 @@
|
||||
.good-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
position: relative;
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.good-content {
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
max-height: 60vh;
|
||||
overflow: hidden;
|
||||
|
||||
.wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.good-info-wrapper {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goods-info {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
|
||||
.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: 12px;
|
||||
|
||||
.currency {
|
||||
font-size: 14px;
|
||||
color: #ff6b35;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 20px;
|
||||
color: #ff6b35;
|
||||
font-weight: 600;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.price-desc {
|
||||
font-size: 14px;
|
||||
color: #333-grey;
|
||||
font-weight: 400;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.goods-service-list {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.service-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.goods-service-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
|
||||
.service-label,
|
||||
.service-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.service-label {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.service-value {
|
||||
color: #333-grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quantity-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.user-form-list {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
padding-bottom: 24px;
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.total-count {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.total-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
font-size: 24px;
|
||||
color: #f55726;
|
||||
|
||||
&::before {
|
||||
content: "¥";
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
width: 160px;
|
||||
height: 48px;
|
||||
background: linear-gradient(179deg, #0ccd58 0%, $theme-color-700 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;
|
||||
margin-left: auto;
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
<template>
|
||||
<div class="scenic-image-card relative rounded-[24px] overflow-hidden w-full" :class="{ 'is-disabled': disabled }"
|
||||
@click="handleSelect">
|
||||
<img class="scenic-image-card__image block w-full" :src="data.image" mode="aspectFill" />
|
||||
<div class="h-[324px] bg-[#e2e8f0] relative rounded-[24px] overflow-hidden w-full active:opacity-86"
|
||||
:class="{ 'opacity-55': disabled }" @click="handleSelect">
|
||||
<img class="h-full block w-full" :src="data.image" />
|
||||
|
||||
<div
|
||||
class="scenic-image-card__expand flex items-center justify-center rounded-full text-white text-[18px] font-bold"
|
||||
class="absolute top-4 right-4 z-10 w-10 h-10 bg-slate-900/35 leading-10 flex items-center justify-center rounded-full text-white text-[18px] font-bold active:opacity-86"
|
||||
@click.stop="handleAction">
|
||||
{{ action.icon }}
|
||||
</div>
|
||||
|
||||
<div v-if="hasCaption" class="scenic-image-card__caption">
|
||||
<div v-if="caption.title" class="scenic-image-card__title text-white text-[18px] font-bold truncate">
|
||||
<div v-if="hasCaption"
|
||||
class="absolute left-0 right-0 bottom-0 pt-[72px] px-[22px] pb-5 bg-gradient-to-b from-slate-900/0 to-slate-900/72">
|
||||
<div v-if="caption.title"
|
||||
class="leading-[24px] [text-shadow:0_2px_8px_rgba(0,0,0,0.28)] text-white text-[18px] font-bold truncate">
|
||||
{{ caption.title }}
|
||||
</div>
|
||||
<div v-if="caption.subtitle" class="scenic-image-card__subtitle text-white text-[14px] font-bold truncate">
|
||||
<div v-if="caption.subtitle"
|
||||
class="mt-1 leading-[18px] [text-shadow:0_2px_8px_rgba(0,0,0,0.28)] text-white text-[14px] font-bold truncate">
|
||||
{{ caption.subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,8 +52,4 @@ const handleAction = () => {
|
||||
if (props.disabled) return;
|
||||
emit("action", props.data);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
</script>
|
||||
Reference in New Issue
Block a user