Compare commits

...

2 Commits

Author SHA1 Message Date
duanshuwen
9546662cc9 Merge branch 'order-729'
合并order-729分支代码
2025-08-03 18:08:22 +08:00
duanshuwen
5e0d53fc20 feat: 商品详情交互开发 2025-08-03 18:06:06 +08:00
22 changed files with 1906 additions and 582 deletions

View File

@ -0,0 +1,461 @@
# FormCard 表单卡片组件
一个功能完整的表单卡片组件,支持姓名和手机号输入,具备数据验证和双向绑定功能。
## 功能特性
- 📝 **双向绑定**:支持 v-model 双向数据绑定
- ✅ **数据验证**:内置手机号格式验证
- 🎨 **自定义标题**:可配置游客标题文本
- 🗑️ **删除功能**:支持删除操作,可配置显示/隐藏
- 💫 **交互反馈**:输入框聚焦效果和错误状态提示
- 📱 **响应式设计**:适配不同屏幕尺寸
- 🎯 **事件支持**:完整的事件系统
- ⚡ **性能优化**:使用计算属性优化渲染
## 基础用法
### 默认使用
```vue
<template>
<FormCard
:form="form"
@update:name="form.name = $event"
@update:phone="form.phone = $event"
@delete="handleDelete"
/>
</template>
<script setup>
import { reactive } from 'vue'
import FormCard from '@/components/FormCard/index.vue'
const form = reactive({
name: '',
phone: ''
})
const handleDelete = () => {
console.log('删除表单')
}
</script>
```
### 自定义标题
```vue
<template>
<FormCard
:form="form"
title="成人票"
@update:name="form.name = $event"
@update:phone="form.phone = $event"
@delete="handleDelete"
/>
</template>
```
### 隐藏删除图标
```vue
<template>
<FormCard
:form="form"
title="联系人信息"
:show-delete-icon="false"
@update:name="form.name = $event"
@update:phone="form.phone = $event"
/>
</template>
```
### 多个表单卡片
```vue
<template>
<view>
<FormCard
v-for="(item, index) in formList"
:key="index"
:form="item"
:title="`游客${index + 1}`"
@update:name="item.name = $event"
@update:phone="item.phone = $event"
@delete="handleDeleteForm(index)"
/>
<button @click="addForm">添加游客</button>
</view>
</template>
<script setup>
import { ref } from 'vue'
const formList = ref([
{ name: '', phone: '' },
{ name: '', phone: '' }
])
const handleDeleteForm = (index) => {
formList.value.splice(index, 1)
}
const addForm = () => {
formList.value.push({ name: '', phone: '' })
}
</script>
```
### 表单验证
```vue
<template>
<FormCard
ref="formCardRef"
:form="form"
title="验证示例"
@update:name="form.name = $event"
@update:phone="form.phone = $event"
/>
<button @click="validateForm">验证表单</button>
</template>
<script setup>
import { ref, reactive } from 'vue'
const formCardRef = ref()
const form = reactive({
name: '',
phone: ''
})
const validateForm = () => {
// 手动触发验证
formCardRef.value.validateName()
formCardRef.value.validatePhone()
// 或者使用工具函数检查
const nameError = formCardRef.value.getNameError(form.name)
const phoneError = formCardRef.value.getPhoneError(form.phone)
if (!nameError && !phoneError) {
console.log('表单验证通过')
}
}
</script>
```
## API 文档
### Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|---------|
| title | String | "游客1" | 表单卡片标题 |
| form | Object | `{ name: '', phone: '' }` | 表单数据对象,包含 name 和 phone 字段 |
| form.name | String | "" | 姓名值 |
| form.phone | String | "" | 手机号值 |
| showDeleteIcon | Boolean | true | 是否显示删除图标 |
### Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:name | (value: string) | 姓名值更新时触发,自动去除首尾空格 |
| update:phone | (value: string) | 手机号值更新时触发,自动过滤非数字字符 |
| delete | - | 点击删除图标时触发 |
### Methods (通过 ref 调用)
| 方法名 | 参数 | 返回值 | 说明 |
|--------|------|--------|---------|
| validateName | - | void | 手动触发姓名验证 |
| validatePhone | - | void | 手动触发手机号验证 |
| getNameError | (name: string) | string | 获取姓名验证错误信息 |
| getPhoneError | (phone: string) | string | 获取手机号验证错误信息 |
### 数据验证
组件内置完整的表单验证:
- **姓名验证**:不能为空,自动去除首尾空格
- **手机号验证**支持中国大陆手机号格式1开头第二位为3-9总长度11位
- **失焦验证**:只在输入框失去焦点时进行验证,避免输入干扰
- **错误提示**:验证失败时显示错误信息,带有淡入动画效果
- **视觉反馈**:输入框边框变红提示错误状态
- **自动过滤**:手机号输入时自动过滤非数字字符
## 样式定制
### CSS 变量系统
组件使用 CSS 变量系统,支持主题定制:
```scss
:root {
--form-primary-color: #00a6ff; // 主色调
--form-error-color: #ff4d4f; // 错误色
--form-text-color: #333; // 文本色
--form-label-color: #86909c; // 标签色
--form-border-color: #e5e8ef; // 边框色
--form-input-border-color: #ddd; // 输入框边框色
--form-bg-color: #fff; // 背景色
--form-header-bg-color: rgba(25, 144, 255, 0.06); // 头部背景色
--form-border-radius: 8px; // 圆角大小
--form-transition: all 0.2s ease; // 过渡动画
}
```
### 主要样式类
```scss
.form-wrapper {
// 表单容器,支持悬停效果和阴影
}
.form-header {
// 表单头部,包含标题和删除按钮
}
.form-title {
// 标题文本,支持文本溢出省略
}
.form-item {
// 表单项容器,支持分隔线
}
.form-input {
// 输入框,支持聚焦和错误状态
}
.form-error {
// 错误信息,带有淡入动画
}
```
### 响应式设计
组件内置响应式支持,在小屏幕设备上自动调整:
- 320px 以下设备优化布局
- 自动调整字体大小和间距
- 保持良好的可用性
### 自定义主题
通过覆盖 CSS 变量来自定义主题:
```scss
// 自定义主题色
:root {
--form-primary-color: #your-primary-color;
--form-error-color: #your-error-color;
--form-border-radius: 12px;
}
// 或者针对特定组件
.your-custom-form {
--form-primary-color: #your-primary-color;
--form-header-bg-color: rgba(your-color, 0.1);
}
```
## 高级用法
### 表单验证集成
```vue
<template>
<FormCard
ref="formCardRef"
:form="form"
title="游客信息"
@update:name="form.name = $event"
@update:phone="form.phone = $event"
@delete="handleDelete"
/>
<button @click="validateForm">提交</button>
</template>
<script setup>
import { reactive, ref } from 'vue'
const formCardRef = ref()
const form = reactive({
name: '',
phone: ''
})
const validateForm = () => {
// 使用组件内置的验证方法
const nameError = formCardRef.value.getNameError(form.name)
const phoneError = formCardRef.value.getPhoneError(form.phone)
if (nameError) {
uni.showToast({
title: nameError,
icon: 'none'
})
return
}
if (phoneError) {
uni.showToast({
title: phoneError,
icon: 'none'
})
return
}
// 提交表单
console.log('表单数据:', form)
}
</script>
```
### 动态表单管理
```vue
<template>
<view>
<FormCard
v-for="(item, index) in passengers"
:key="item.id"
:form="item"
@update:name="item.name = $event"
@update:phone="item.phone = $event"
:title="getPassengerTitle(item.type, index)"
:show-delete-icon="passengers.length > 1"
@delete="removePassenger(index)"
/>
<view class="action-buttons">
<button @click="addAdult">添加成人</button>
<button @click="addChild">添加儿童</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const passengers = ref([
{ id: 1, type: 'adult', name: '', phone: '' }
])
let nextId = 2
const getPassengerTitle = (type, index) => {
return type === 'adult' ? `成人${index + 1}` : `儿童${index + 1}`
}
const addAdult = () => {
passengers.value.push({
id: nextId++,
type: 'adult',
name: '',
phone: ''
})
}
const addChild = () => {
passengers.value.push({
id: nextId++,
type: 'child',
name: '',
phone: ''
})
}
const removePassenger = (index) => {
if (passengers.value.length > 1) {
passengers.value.splice(index, 1)
}
}
</script>
```
## 注意事项
1. **数据传递**:使用 `:form` 对象传递数据,包含 `name``phone` 字段
2. **双向绑定**:通过 `@update:name``@update:phone` 事件进行双向绑定
3. **验证机制**:只在失去焦点时进行验证,避免输入干扰
4. **手机号验证**仅支持中国大陆手机号格式验证1开头第二位3-9总长度11位
5. **自动处理**:手机号自动过滤非数字字符,姓名自动去除首尾空格
6. **删除功能**:删除事件需要父组件处理具体逻辑
7. **方法调用**:通过 `ref` 可调用组件内部的验证方法
8. **兼容性**支持微信小程序、H5、App等平台
## 更新日志
### v1.3.0 (2024-12-19)
**性能与可维护性全面优化**
- 🚀 **性能优化**:提取常量定义,优化计算属性逻辑
- 🛠️ **代码重构**:添加完整的 JSDoc 注释和类型定义
- 🎨 **样式升级**:使用 CSS 变量系统,支持主题定制
- ✨ **功能增强**:手机号自动过滤非数字字符,姓名自动去除空格
- 🎭 **UI 改进**:新增悬停效果、错误信息动画和阴影效果
- 📱 **响应式设计**:优化小屏幕设备适配
- 🔧 **开发体验**:添加 defineExpose 暴露验证方法,便于测试
- 📝 **文档完善**:更新演示和使用说明
### v1.2.3 (2024-12-19)
**优化验证行为**
- 🎨 优化验证行为,移除实时验证
- ✨ 姓名和手机号只在失去焦点时进行验证
- 🔧 移除不必要的 watch 监听器
- 📝 更新文档和演示说明
- ⚡ 提升组件性能和用户体验
### v1.2.2 (2024-12-19)
**新增姓名验证功能**
- ✨ 新增姓名非空验证功能
- 👤 姓名为空时显示"请输入姓名"提示
- 🔄 支持姓名实时验证,输入内容时错误信息自动隐藏
- 🎯 完善表单验证体系,提升数据完整性
- 💫 优化用户体验,提供友好的输入提示
### v1.2.1 (2024-12-19)
**优化手机号验证功能**
- 🐛 修复validatePhone方法中props引用错误的问题
- ✨ 新增手机号实时验证功能
- 🔄 输入正确手机号时错误信息自动隐藏
- 📱 优化用户输入体验,提供即时反馈
- 🎯 完善demo页面增加功能说明
### v1.2.0 (2024-12-19)
**新增删除功能**
- ✨ 支持删除表单卡片
- 🎯 可配置删除图标显示/隐藏
- 🔄 完善事件系统支持delete事件
- 💫 优化用户交互体验
### v2.0.0
- ✨ 重构组件,支持 props 传值和双向绑定
- ✨ 新增 `title` 属性,支持自定义标题
- ✨ 新增 `showDeleteIcon` 属性,控制删除图标显示
- ✨ 新增完整的事件系统update:name, update:phone, delete
- 🎨 优化样式,新增错误状态和交互效果
- 🔧 改进手机号验证逻辑
- 📝 新增完整的文档和演示示例
### v1.0.0
- 🎉 初始版本发布
- ✨ 基础表单功能
- ✨ 手机号验证
- ✨ 基础样式
## 技术栈
- Vue 3 Composition API
- SCSS
- uni-app
## 浏览器支持
- 微信小程序
- H5 (Chrome, Firefox, Safari, Edge)
- App (iOS, Android)
## 许可证
MIT License

View File

@ -0,0 +1,256 @@
<template>
<view class="demo-container">
<view class="demo-title">FormCard 表单组件演示</view>
<view class="demo-description">
<text> 支持姓名和手机号输入自动过滤非数字字符</text>
<text>👤 姓名失去焦点时验证自动去除首尾空格</text>
<text>📱 手机号失去焦点时验证支持中国大陆手机号格式</text>
<text>🎨 优化UI设计支持悬停效果和动画</text>
<text>🗑 支持删除操作可配置显示/隐藏</text>
<text>📱 响应式设计适配小屏幕设备</text>
</view>
<!-- 示例1: 基础用法 -->
<view class="demo-section">
<view class="section-title">示例1: 基础用法</view>
<FormCard
:form="form1"
title="游客1"
@update:name="form1.name = $event"
@update:phone="form1.phone = $event"
@delete="handleDelete1"
/>
<view class="form-data">
<text>姓名: {{ form1.name }}</text>
<text>手机号: {{ form1.phone }}</text>
</view>
</view>
<!-- 示例2: 自定义标题 -->
<view class="demo-section">
<view class="section-title">示例2: 自定义标题</view>
<FormCard
:form="form2"
title="成人票"
@update:name="form2.name = $event"
@update:phone="form2.phone = $event"
@delete="handleDelete2"
/>
<view class="form-data">
<text>姓名: {{ form2.name }}</text>
<text>手机号: {{ form2.phone }}</text>
</view>
</view>
<!-- 示例3: 隐藏删除图标 -->
<view class="demo-section">
<view class="section-title">示例3: 隐藏删除图标</view>
<FormCard
:form="form3"
title="联系人信息"
:show-delete-icon="false"
@update:name="form3.name = $event"
@update:phone="form3.phone = $event"
/>
<view class="form-data">
<text>姓名: {{ form3.name }}</text>
<text>手机号: {{ form3.phone }}</text>
</view>
</view>
<!-- 示例4: 多个表单卡片 -->
<view class="demo-section">
<view class="section-title">示例4: 多个表单卡片</view>
<FormCard
v-for="(item, index) in formList"
:key="index"
:form="item"
:title="`游客${index + 1}`"
@update:name="item.name = $event"
@update:phone="item.phone = $event"
@delete="handleDeleteForm(index)"
/>
<button class="add-btn" @click="addForm">添加游客</button>
<view class="form-list-data">
<view class="list-title">表单数据:</view>
<view v-for="(item, index) in formList" :key="index" class="list-item">
<text>游客{{ index + 1 }}: {{ item.name }} - {{ item.phone }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue'
import FormCard from './index.vue'
//
const form1 = reactive({
name: '',
phone: ''
})
const form2 = reactive({
name: '张三',
phone: '13800138000'
})
const form3 = reactive({
name: '',
phone: ''
})
//
const formList = ref([
{ name: '', phone: '' },
{ name: '', phone: '' }
])
//
const handleDelete1 = () => {
console.log('删除表单1')
uni.showToast({
title: '删除表单1',
icon: 'none'
})
}
const handleDelete2 = () => {
console.log('删除表单2')
uni.showToast({
title: '删除表单2',
icon: 'none'
})
}
const handleDeleteForm = (index) => {
console.log(`删除表单${index + 1}`)
formList.value.splice(index, 1)
uni.showToast({
title: `删除游客${index + 1}`,
icon: 'none'
})
}
const addForm = () => {
formList.value.push({ name: '', phone: '' })
uni.showToast({
title: '添加游客成功',
icon: 'none'
})
}
</script>
<style scoped lang="scss">
.demo-container {
padding: 20rpx;
background-color: #f5f5f5;
min-height: 100vh;
}
.demo-title {
font-size: 32rpx;
font-weight: bold;
text-align: center;
margin-bottom: 20rpx;
color: #333;
}
.demo-description {
margin-bottom: 40rpx;
padding: 20rpx;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border-radius: 12rpx;
border: 1px solid #bae6fd;
text {
display: block;
font-size: 26rpx;
color: #0369a1;
margin-bottom: 8rpx;
line-height: 1.5;
&:last-child {
margin-bottom: 0;
}
}
}
.demo-section {
margin-bottom: 60rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
margin-bottom: 20rpx;
color: #333;
border-left: 6rpx solid #00a6ff;
padding-left: 16rpx;
}
.form-data {
margin-top: 20rpx;
padding: 20rpx;
background: #fff;
border-radius: 8rpx;
border: 1px solid #e5e8ef;
text {
display: block;
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
&:last-child {
margin-bottom: 0;
}
}
}
.add-btn {
width: 100%;
height: 80rpx;
background: #00a6ff;
color: #fff;
border: none;
border-radius: 8rpx;
font-size: 28rpx;
margin-top: 20rpx;
&:active {
background: #0056b3;
}
}
.form-list-data {
margin-top: 30rpx;
padding: 20rpx;
background: #fff;
border-radius: 8rpx;
border: 1px solid #e5e8ef;
}
.list-title {
font-size: 26rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.list-item {
margin-bottom: 8rpx;
text {
font-size: 24rpx;
color: #666;
}
&:last-child {
margin-bottom: 0;
}
}
</style>

View File

@ -1,44 +1,169 @@
<template>
<view class="form-wrapper">
<view class="form-header">
<image class="form-icon" src="./images/icon_minus.png"></image>
<text class="form-title">游客1</text>
</view>
<view class="form-item">
<text class="form-label"> </text>
<input class="form-input" v-model="name" placeholder="请输入姓名" />
</view>
<view class="form-item">
<text class="form-label">手机号</text>
<input
class="form-input"
v-model="phone"
placeholder="请输入手机号"
@blur="validatePhone"
<uni-icons class="minus" color="#00A6FF" size="22" type="minus" />
<text class="form-title">{{ title }}</text>
<uni-icons
v-if="showDeleteIcon"
class="delete"
color="#00A6FF"
size="22"
type="trash"
@click="handleDelete"
/>
</view>
<view class="form-item-wrapper">
<view class="form-item">
<view class="form-item-row">
<text class="form-label"> </text>
<input
class="form-input"
:class="{ 'form-input-error': nameError }"
v-model="nameValue"
placeholder="请输入姓名"
@blur="validateName"
/>
</view>
<text v-if="nameError" class="form-error">{{ nameError }}</text>
</view>
<view class="form-item">
<view class="form-item-row">
<text class="form-label">手机号</text>
<input
class="form-input"
:class="{ 'form-input-error': phoneError }"
v-model="phoneValue"
placeholder="请输入手机号"
type="tel"
maxlength="11"
@blur="validatePhone"
/>
</view>
<text v-if="phoneError" class="form-error">{{ phoneError }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { ref, computed } from "vue";
// Local state
const name = ref("");
const phone = ref("");
//
const PHONE_REGEX = /^1[3-9]\d{9}$/;
const ERROR_MESSAGES = {
NAME_REQUIRED: "请输入姓名",
PHONE_REQUIRED: "手机号不能为空",
PHONE_INVALID: "请输入正确的手机号",
};
/**
* FormCard 组件 Props
* @typedef {Object} FormCardProps
* @property {string} title - 表单标题
* @property {Object} form - 表单数据对象
* @property {string} form.name - 姓名
* @property {string} form.phone - 手机号
* @property {boolean} showDeleteIcon - 是否显示删除图标
*/
const props = defineProps({
title: {
type: String,
default: "游客1",
},
form: {
type: Object,
default: () => ({
name: "",
phone: "",
}),
validator: (value) => {
return value && typeof value === 'object' &&
'name' in value && 'phone' in value;
},
},
showDeleteIcon: {
type: Boolean,
default: true,
},
});
/**
* FormCard 组件事件
* @typedef {Object} FormCardEmits
* @property {Function} update:name - 更新姓名事件
* @property {Function} update:phone - 更新手机号事件
* @property {Function} delete - 删除表单事件
*/
const emit = defineEmits(["update:name", "update:phone", "delete"]);
//
const nameError = ref("");
const phoneError = ref("");
// Methods
const validatePhone = () => {
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phone.value) {
phoneError.value = "手机号不能为空";
} else if (!phoneRegex.test(phone.value)) {
phoneError.value = "请输入正确的手机号";
} else {
phoneError.value = "";
// -
const nameValue = computed({
get: () => props.form?.name || "",
set: (value) => emit("update:name", value?.trim() || ""),
});
const phoneValue = computed({
get: () => props.form?.phone || "",
set: (value) => {
//
const numericValue = value.replace(/\D/g, "");
emit("update:phone", numericValue);
},
});
//
/**
* 验证姓名是否有效
* @param {string} name - 姓名
* @returns {string} 错误信息空字符串表示验证通过
*/
const getNameError = (name) => {
if (!name || name.trim() === "") {
return ERROR_MESSAGES.NAME_REQUIRED;
}
return "";
};
/**
* 验证手机号是否有效
* @param {string} phone - 手机号
* @returns {string} 错误信息空字符串表示验证通过
*/
const getPhoneError = (phone) => {
if (!phone) {
return ERROR_MESSAGES.PHONE_REQUIRED;
}
if (!PHONE_REGEX.test(phone)) {
return ERROR_MESSAGES.PHONE_INVALID;
}
return "";
};
//
const validateName = () => {
nameError.value = getNameError(props.form?.name);
};
const validatePhone = () => {
phoneError.value = getPhoneError(props.form?.phone);
};
//
const handleDelete = () => {
emit("delete");
};
//
defineExpose({
validateName,
validatePhone,
getNameError,
getPhoneError,
});
</script>
<style scoped lang="scss">

View File

@ -1,46 +1,146 @@
// SASS 变量定义
$form-primary-color: #00a6ff;
$form-error-color: #ff4d4f;
$form-text-color: #333;
$form-label-color: #86909c;
$form-border-color: #ddd;
$form-input-border-color: #ddd;
$form-bg-color: #fff;
$form-header-bg-color: rgba(25, 144, 255, 0.06);
$form-border-radius: 8px;
$form-transition: all 0.2s ease;
.form-wrapper {
background-color: #fff;
border-radius: 8px;
display: inline-block;
font-size: 0;
border-radius: $form-border-radius;
overflow: hidden;
width: 280px;
margin-right: 12px;
}
.form-header {
background-color: rgba(25, 144, 255, 0.06);
border: 1px solid #e5e8ef;
border-radius: 8px 8px 0 0;
background-color: $form-header-bg-color;
box-sizing: border-box;
border: 1px solid $form-border-color;
border-radius: $form-border-radius $form-border-radius 0 0;
display: flex;
align-items: center;
padding: 10px 12px;
}
min-height: 44px;
.form-icon {
height: 16px;
width: 16px;
margin-right: 8px;
.minus,
.delete {
height: 22px;
width: 22px;
cursor: pointer;
transition: $form-transition;
&:hover {
transform: scale(1.1);
}
}
.delete {
margin-left: auto;
}
}
.form-title {
margin-left: 8px;
font-size: 16px;
color: #00a6ff;
font-weight: 500;
color: $form-primary-color;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-item-wrapper {
background-color: $form-bg-color;
border-radius: 0 0 $form-border-radius $form-border-radius;
box-sizing: border-box;
padding: 12px;
}
.form-item {
display: flex;
align-items: flex-start;
flex-direction: column;
position: relative;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.form-item-row {
display: flex;
align-items: center;
padding: 12px 24px 12px 16px;
width: 100%;
}
.form-label {
font-size: 16px;
color: #86909c;
margin-right: 10px;
font-size: 14px;
color: $form-label-color;
width: 50px;
flex-shrink: 0;
font-weight: 500;
margin-right: 10px;
}
.form-input {
flex: 1;
font-size: 16px;
color: #333;
border-bottom: 1px solid #ddd;
padding-bottom: 6px;
font-size: 14px;
color: $form-text-color;
border: none;
border-bottom: 1px solid $form-input-border-color;
background: transparent;
outline: none;
transition: $form-transition;
min-height: 20px;
&::placeholder {
color: $form-label-color;
opacity: 0.8;
}
&:focus {
border-bottom-color: $form-primary-color;
&::placeholder {
opacity: 0.5;
}
}
&.form-input-error {
border-bottom-color: $form-error-color;
&:focus {
border-bottom-color: $form-error-color;
}
}
}
.form-error {
font-size: 12px;
color: $form-error-color;
margin-top: 6px;
margin-left: 60px;
line-height: 1.4;
animation: fadeInError 0.3s ease-in-out;
}
// 错误信息淡入动画
@keyframes fadeInError {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -5,7 +5,9 @@
## 功能特性
- 🎨 **可配置圆角**:支持数字(px)或字符串形式的圆角设置
- 📏 **可配置高度**:支持数字(px)或字符串形式的高度设置
- 🖼️ **缩略图导航**:底部缩略图快速切换,支持左右滑动
- 👁️ **缩略图控制**:可配置显示或隐藏缩略图
- 📱 **响应式设计**:适配不同屏幕尺寸
- 🎯 **自定义数据**:支持传入自定义图片数据
- 📊 **进度指示器**:显示当前图片位置
@ -42,6 +44,37 @@ import ImageSwiper from '@/components/ImageSwiper/index.vue'
</template>
```
### 自定义高度
```vue
<template>
<!-- 数字形式 (px) -->
<ImageSwiper :height="300" />
<!-- 字符串形式 -->
<ImageSwiper height="50vh" />
<!-- 小高度轮播图 -->
<ImageSwiper :height="120" />
</template>
```
### 隐藏缩略图
```vue
<template>
<!-- 隐藏缩略图,只显示主轮播图 -->
<ImageSwiper :show-thumbnails="false" />
<!-- 结合其他属性使用 -->
<ImageSwiper
:height="250"
:border-radius="15"
:show-thumbnails="false"
/>
</template>
```
### 自定义图片数据
```vue
@ -97,6 +130,8 @@ const manyImages = ref([
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| borderRadius | Number \| String | 8 | 轮播图圆角大小数字时单位为px |
| height | Number \| String | 200 | 轮播图高度数字时单位为px |
| showThumbnails | Boolean | true | 是否显示缩略图 |
| images | Array | [] | 图片数据数组,为空时使用默认数据 |
### images 数组结构
@ -157,21 +192,79 @@ const handleChange = (e) => {
## 高级用法
### 响应式圆角
### 响应式配置
```vue
<template>
<ImageSwiper :border-radius="responsiveRadius" />
<ImageSwiper
:border-radius="responsiveRadius"
:height="responsiveHeight"
:show-thumbnails="showThumbnails"
/>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
// 根据屏幕宽度动态调整圆角
// 根据屏幕宽度动态调整圆角和高度
const responsiveRadius = computed(() => {
const screenWidth = uni.getSystemInfoSync().screenWidth
return screenWidth > 750 ? 16 : 8
})
const responsiveHeight = computed(() => {
const screenWidth = uni.getSystemInfoSync().screenWidth
return screenWidth > 750 ? 300 : 200
})
// 动态控制缩略图显示
const showThumbnails = ref(true)
</script>
```
### 动态控制示例
```vue
<template>
<view>
<!-- 控制面板 -->
<view class="control-panel">
<text>高度: {{ dynamicHeight }}px</text>
<slider
:value="dynamicHeight"
:min="100"
:max="400"
@change="handleHeightChange"
/>
<view class="checkbox-wrapper">
<checkbox :checked="showThumbnails" @change="handleThumbnailToggle" />
<text>显示缩略图</text>
</view>
</view>
<!-- 轮播图组件 -->
<ImageSwiper
:height="dynamicHeight"
:show-thumbnails="showThumbnails"
:border-radius="10"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
const dynamicHeight = ref(200)
const showThumbnails = ref(true)
const handleHeightChange = (e) => {
dynamicHeight.value = e.detail.value
}
const handleThumbnailToggle = (e) => {
showThumbnails.value = e.detail.value
}
</script>
```
@ -196,12 +289,22 @@ const themeRadius = computed(() => {
## 注意事项
1. **圆角单位**数字类型自动添加px单位字符串类型直接使用
2. **图片比例**:建议使用相同比例的图片以获得最佳显示效果
3. **性能优化**:大量图片时建议使用懒加载
4. **兼容性**支持微信小程序、H5、App等平台
2. **高度单位**数字类型自动添加px单位字符串类型直接使用支持vh、rem等
3. **缩略图显示**:当设置 `showThumbnails``false` 时,缩略图完全隐藏
4. **图片比例**:建议使用相同比例的图片以获得最佳显示效果
5. **性能优化**:大量图片时建议使用懒加载
6. **兼容性**支持微信小程序、H5、App等平台
## 更新日志
### v1.3.0
- ✨ 新增 `height` 属性,支持自定义轮播图高度
- ✨ 新增 `showThumbnails` 属性,支持隐藏缩略图
- 🎨 优化样式系统,移除硬编码高度
- 🔧 改进计算属性,支持动态高度和缩略图控制
- 📝 更新文档和演示示例,新增多个高级用法示例
- 🎯 增强组件灵活性,适应更多使用场景
### v1.2.0
- ✨ 新增缩略图左右滑动功能
- ✨ 新增缩略图选中状态高亮显示

View File

@ -53,6 +53,54 @@
</view>
<ImageSwiper :border-radius="dynamicRadius" />
</view>
<!-- 示例8: 自定义高度 -->
<view class="demo-section">
<view class="section-title">示例8: 自定义高度 (300px)</view>
<ImageSwiper :height="300" :border-radius="12" />
</view>
<!-- 示例9: 小高度轮播图 -->
<view class="demo-section">
<view class="section-title">示例9: 小高度轮播图 (120px)</view>
<ImageSwiper :height="120" :border-radius="8" />
</view>
<!-- 示例10: 隐藏缩略图 -->
<view class="demo-section">
<view class="section-title">示例10: 隐藏缩略图</view>
<ImageSwiper :show-thumbnails="false" :border-radius="15" />
</view>
<!-- 示例11: 动态高度和缩略图控制 -->
<view class="demo-section">
<view class="section-title">示例11: 动态高度和缩略图控制</view>
<view class="control-panel">
<text>高度: {{ dynamicHeight }}px</text>
<slider
:value="dynamicHeight"
:min="100"
:max="400"
@change="handleHeightChange"
activeColor="#007AFF"
/>
<view class="checkbox-wrapper">
<checkbox :checked="showThumbnails" @change="handleThumbnailToggle" />
<text class="checkbox-label">显示缩略图</text>
</view>
</view>
<ImageSwiper
:height="dynamicHeight"
:show-thumbnails="showThumbnails"
:border-radius="10"
/>
</view>
<!-- 示例12: 字符串高度 -->
<view class="demo-section">
<view class="section-title">示例12: 字符串高度 (50vh)</view>
<ImageSwiper height="50vh" :border-radius="20" :show-thumbnails="false" />
</view>
</view>
</template>
@ -63,6 +111,12 @@ import ImageSwiper from './index.vue'
//
const dynamicRadius = ref(8)
//
const dynamicHeight = ref(200)
//
const showThumbnails = ref(true)
//
const customImages = ref([
{
@ -97,6 +151,16 @@ const manyImages = ref([
const handleRadiusChange = (e) => {
dynamicRadius.value = e.detail.value
}
//
const handleHeightChange = (e) => {
dynamicHeight.value = e.detail.value
}
//
const handleThumbnailToggle = (e) => {
showThumbnails.value = e.detail.value
}
</script>
<style scoped lang="scss">
@ -148,4 +212,16 @@ const handleRadiusChange = (e) => {
slider {
width: 100%;
}
.checkbox-wrapper {
display: flex;
align-items: center;
margin-top: 20rpx;
}
.checkbox-label {
margin-left: 16rpx;
font-size: 24rpx;
color: #666;
}
</style>

View File

@ -2,7 +2,7 @@
<view class="image-swiper">
<swiper
class="swiper-box"
:style="borderRadiusStyle"
:style="swiperStyle"
:autoplay="false"
:interval="3000"
:duration="1000"
@ -23,7 +23,7 @@
</view>
<!-- 缩略图部分 -->
<view class="thumbnail-box">
<view v-if="showThumbnails" class="thumbnail-box">
<scroll-view
class="thumbnail-scroll"
scroll-x="true"
@ -63,6 +63,16 @@ const props = defineProps({
type: Array,
default: () => [],
},
// (px)
height: {
type: [Number, String],
default: 200,
},
//
showThumbnails: {
type: Boolean,
default: true,
},
});
const active = ref(0);
@ -79,6 +89,20 @@ const borderRadiusStyle = computed(() => {
};
});
//
const swiperStyle = computed(() => {
const radius =
typeof props.borderRadius === "number"
? `${props.borderRadius}px`
: props.borderRadius;
const swiperHeight =
typeof props.height === "number" ? `${props.height}px` : props.height;
return {
borderRadius: radius,
height: swiperHeight,
};
});
//
const defaultImages = [
{

View File

@ -4,9 +4,8 @@
}
.swiper-box {
height: 200px;
overflow: hidden;
// 圆角通过内联样式动态设置
// 高度和圆角通过内联样式动态设置
}
.swiper-item image {

View File

@ -0,0 +1,72 @@
# Stepper 数字步进器组件
一个简洁易用的数字步进器组件,支持增减操作和数值范围限制。
## 功能特性
- ✨ **双向数据绑定**:支持 v-model 语法糖
- 🔢 **数值范围控制**:可设置最小值和最大值
- 🎯 **响应式更新**:实时响应外部数值变化
- 🎨 **简洁UI设计**:使用 uni-icons 图标,界面清爽
- 📱 **移动端适配**:完美适配各种屏幕尺寸
## 使用方法
### 基础用法
```vue
<template>
<Stepper v-model="quantity" />
</template>
<script setup>
import { ref } from 'vue'
import Stepper from '@/components/Stepper/index.vue'
const quantity = ref(1)
</script>
```
### 设置数值范围
```vue
<template>
<Stepper
v-model="quantity"
:min="1"
:max="10"
/>
</template>
```
## API
### Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| modelValue | Number | 1 | 当前数值,支持 v-model |
| min | Number | 1 | 最小值 |
| max | Number | 100 | 最大值 |
### Events
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| update:modelValue | 数值变化时触发 | (value: number) |
## 更新日志
### v1.1.0 (2024-12-19)
**修复响应性问题**
- 🐛 修复组件不响应外部 modelValue 变化的问题
- 🔄 添加 watch 监听器,确保实时同步外部值变化
- ✨ 提升组件响应性和数据同步准确性
- 🎯 完善与父组件的双向数据绑定
### v1.0.0
**初始版本**
- ✨ 基础数字步进器功能
- 🔢 支持数值范围控制
- 🎨 简洁的UI设计
- 📱 移动端适配

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,23 +1,13 @@
<template>
<view class="stepper-wrapper">
<image
class="stepper-btn stepper-btn-minus"
src="./images/icon_minus.png"
mode="aspectFill"
@click="decrease"
></image>
<uni-icons type="minus" size="24" color="#999" @click="decrease" />
<text class="stepper-text">{{ value }}</text>
<image
class="stepper-btn stepper-btn-plus"
src="./images/icon_plus.png"
mode="aspectFill"
@click="increase"
></image>
<uni-icons type="plus" size="24" color="#999" @click="increase" />
</view>
</template>
<script setup>
import { ref, defineProps, defineEmits } from "vue";
import { ref, defineProps, defineEmits, watch } from "vue";
// Props
const props = defineProps({
@ -41,6 +31,15 @@ const emit = defineEmits(["update:modelValue"]);
// Local state
const value = ref(props.modelValue);
//
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
},
{ immediate: true }
);
// Methods
const decrease = () => {
if (value.value === 1) return;

View File

@ -3,22 +3,9 @@
align-items: center;
}
.stepper-btn {
width: 24px;
height: 24px;
border-radius: 50px;
cursor: pointer;
}
.stepper-btn-minus {
margin-right: 10px;
}
.stepper-btn-plus {
margin-left: 10px;
}
.stepper-text {
width: 40px;
font-size: 16px;
color: #333;
text-align: center;
}

View File

@ -1,13 +1,20 @@
.sum-wrapper {
border-radius: 8px;
background-color: #fff;
padding: 0 12px;
box-sizing: border-box;
}
.sum-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 13px 15px;
padding: 12px 0;
border-bottom: 1px solid #ddd;
&:last-child {
border-bottom: none;
}
}
.sum-label {

View File

@ -8,6 +8,10 @@
- 📱 **响应式布局** - 完美适配各种屏幕尺寸
- 🛒 **商品信息展示** - 支持商品图片、标题、价格、标签展示
- 🔢 **数量选择** - 提供加减按钮和手动输入两种方式
- 👥 **动态表单管理** - 根据数量自动添加/删除游客信息表单
- 📝 **表单数据绑定** - 支持姓名和手机号的双向绑定
- 📱 **横向滚动支持** - 游客信息区域支持横向滚动浏览多个表单
- 🗑️ **删除表单支持** - 支持删除游客信息表单,自动同步数量和列表长度
- 💰 **实时计算** - 自动计算并显示总价
- ⚡ **性能优化** - 基于 Vue 3 Composition API性能卓越
- 🎭 **动画效果** - 流畅的弹出和交互动画
@ -53,7 +57,13 @@ const showConfirm = () => {
const handleConfirm = (orderData) => {
console.log('确认订单:', orderData)
// 处理订单确认逻辑
// orderData 包含:
// {
// goodsData: 商品数据,
// quantity: 购买数量,
// totalPrice: 总价,
// userFormList: 游客信息列表 [{ name: '', phone: '' }, ...]
// }
}
const handleClose = () => {
@ -114,6 +124,10 @@ interface OrderData {
goodsData: GoodsData; // 商品数据
quantity: number; // 购买数量
totalPrice: string; // 总价(字符串格式)
userFormList: Array<{ // 游客信息列表
name: string; // 游客姓名
phone: string; // 游客手机号
}>;
}
```
@ -202,9 +216,50 @@ const handleConfirm = async (orderData) => {
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 的底部弹出设计

View File

@ -1,68 +1,68 @@
<template>
<view class="demo-container">
<TopNavBar title="商品确认组件演示" :fixed="true" />
<view class="demo-header">
<text class="demo-title">GoodConfirm 组件演示</text>
</view>
<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>
<button class="demo-btn" @click="showConfirm">显示商品确认弹窗</button>
<button class="demo-btn" @click="setQuantity(5)" style="margin-left: 12px;">设置5人测试横向滚动</button>
</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="status-info">
<text>Stepper数量: {{ quantity }}</text>
<text>表单项数量: {{ userFormCount }}</text>
<text>总价: ¥{{ totalPrice }}</text>
<text class="debug-info">实时quantity值: {{ quantity }}</text>
<text class="feature-highlight"> 支持横向滚动浏览多个游客信息</text>
<text class="feature-highlight">🗑 支持删除游客信息至少保留一位</text>
</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 class="demo-section" v-if="lastOrderData">
<view class="section-title">最后提交的数据</view>
<view class="order-data">
<text>商品: {{ lastOrderData.goodsData.commodityName }}</text>
<text>数量: {{ lastOrderData.quantity }}</text>
<text>总价: ¥{{ lastOrderData.totalPrice }}</text>
<text>用户信息:</text>
<view class="user-list">
<view
v-for="(user, index) in lastOrderData.userFormList"
:key="index"
class="user-item"
>
<text>游客{{ index + 1 }}: {{ user.name || '未填写' }} - {{ user.phone || '未填写' }}</text>
</view>
</view>
</view>
</view>
<!-- 基础演示弹窗 -->
<GoodConfirm
ref="basicDemoRef"
:goodsData="basicGoodsData"
@confirm="handleBasicConfirm"
@close="handleBasicClose"
/>
<!-- 自定义演示弹窗 -->
<GoodConfirm
ref="customDemoRef"
:goodsData="customGoodsData"
@confirm="handleCustomConfirm"
@close="handleCustomClose"
ref="confirmRef"
:goodsData="goodsData"
@confirm="handleConfirm"
@close="handleClose"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import TopNavBar from '@/components/TopNavBar/index.vue'
import { ref, computed } from 'vue'
import GoodConfirm from './index.vue'
//
const basicDemoRef = ref(null)
const customDemoRef = ref(null)
const confirmRef = ref(null)
const quantity = ref(1)
const userFormCount = ref(1)
const lastOrderData = ref(null)
//
const basicGoodsData = ref({
commodityName: '【成人票】云从朵花温泉门票',
price: 399,
timeTag: '随时可退',
const goodsData = ref({
commodityName: '云从朵花温泉票(成人票)',
price: 70,
timeTag: '条件退款',
commodityPhotoList: [
{
photoUrl: '/static/test/mk_img_1.png'
@ -70,62 +70,61 @@ const basicGoodsData = ref({
]
})
//
const customGoodsData = ref({
commodityName: '【亲子套票】海洋世界一日游',
price: 268,
timeTag: '限时优惠',
commodityPhotoList: [
{
photoUrl: '/static/test/mk_img_1.png'
}
]
const totalPrice = computed(() => {
return (goodsData.value.price * quantity.value).toFixed(0)
})
//
const showBasicDemo = () => {
basicDemoRef.value?.showPopup()
const showConfirm = () => {
confirmRef.value?.showPopup()
}
const showCustomDemo = () => {
customDemoRef.value?.showPopup()
}
const handleConfirm = (orderData) => {
console.log('确认订单:', orderData)
lastOrderData.value = orderData
quantity.value = orderData.quantity
userFormCount.value = orderData.userFormList.length
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({
uni.showToast({
title: '订单确认成功',
content: `商品:${orderData.goodsData.commodityName}\n数量${orderData.quantity}\n总价¥${orderData.totalPrice}`,
showCancel: false
icon: 'success'
})
}
const handleCustomClose = () => {
console.log('自定义演示关闭弹窗')
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 {
min-height: 100vh;
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.content-wrapper {
padding: 100px 20px 20px;
.demo-header {
text-align: center;
margin-bottom: 30px;
.demo-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
}
.demo-section {
@ -139,52 +138,84 @@ const handleCustomClose = () => {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #ff6b35;
margin-bottom: 15px;
}
}
.demo-item {
margin-bottom: 12px;
.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);
&: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;
&:active {
transform: translateY(1px);
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;
&::after {
border: none;
}
}
&:last-child {
border-bottom: 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;
}
}
}

View File

@ -1,83 +1,107 @@
<template>
<uni-popup ref="popup" type="bottom">
<view class="good-confirm-container">
<!-- 头部标题栏 -->
<uni-popup
ref="popup"
type="bottom"
background-color="#E9F3F7"
border-radius="12px 12px 0 0"
mask-background-color="rgba(0,0,0,0.5)"
:safe-area="false"
>
<view class="good-container">
<!-- 头部区域 -->
<view class="header">
<view class="header-title">确认订单</view>
<view class="header-title">填写信息</view>
<view class="close-btn" @click="closePopup">
<uni-icons type="closeempty" size="20" color="#666"></uni-icons>
<uni-icons type="closeempty" size="24" color="#333" />
</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"
<scroll-view
class="good-content"
:scroll-y="true"
:show-scrollbar="false"
>
<view class="wrapper">
<view class="good-info-wrapper">
<!-- 轮播图区域 -->
<ImageSwiper
:images="goodsData.commodityPhotoList"
:height="130"
:border-radius="0"
:showThumbnails="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 class="goods-info">
<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-service-list">
<view class="service-title">包含服务</view>
<view class="goods-service-item">
<text class="service-label">随时可退</text>
<text class="service-value">1</text>
</view>
</view>
</view>
</view>
</view>
<!-- 数量选择区域 -->
<view class="quantity-section">
<ModuleTitle title="游客信息" />
<Stepper v-model="quantity" />
</view>
<!-- 游客信息区域 -->
<scroll-view
class="user-form-list"
:scroll-x="true"
:show-scrollbar="false"
>
<FormCard
v-for="(item, index) in userFormList"
:title="`游客${index + 1}`"
:form="item"
:showDeleteIcon="userFormList.length > 1"
:key="index"
@update:name="(value) => updateUserForm(index, 'name', value)"
@update:phone="(value) => updateUserForm(index, 'phone', value)"
@delete="() => deleteUserForm(index)"
/>
</scroll-view>
<!-- 总价区域 -->
<SumCard />
</view>
</view>
</scroll-view>
<!-- 底部按钮区域 -->
<view class="footer">
<button class="confirm-btn" @click="confirmOrder">确认购买</button>
<view class="left">
<text class="total-count">{{ quantity }}合计</text>
<text class="total-price">{{ totalPrice }}</text>
</view>
<view class="confirm-btn" @click="confirmOrder">立即支付</view>
</view>
</view>
</uni-popup>
</template>
<script setup>
import { ref, computed, defineProps, defineEmits } from "vue";
import { ref, computed, watch, defineProps, defineEmits } 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";
// Props
const props = defineProps({
@ -93,6 +117,8 @@ const emits = defineEmits(["confirm", "close"]);
//
const popup = ref(null);
const quantity = ref(1);
const userFormList = ref([{ name: "", phone: "" }]); //
const isDeleting = ref(false); // watch
//
const totalPrice = computed(() => {
@ -100,6 +126,31 @@ const totalPrice = computed(() => {
return (price * quantity.value).toFixed(0);
});
// quantity userFormList
watch(
quantity,
(newQuantity, oldQuantity) => {
// watch
if (isDeleting.value) {
isDeleting.value = false;
return;
}
const currentLength = userFormList.value.length;
if (newQuantity > currentLength) {
//
for (let i = currentLength; i < newQuantity; i++) {
userFormList.value.push({ name: "", phone: "" });
}
} else if (newQuantity < currentLength) {
//
userFormList.value.splice(newQuantity);
}
},
{ immediate: false }
);
//
const showPopup = () => {
popup.value?.open();
@ -110,23 +161,31 @@ const closePopup = () => {
emits("close");
};
const increaseQuantity = () => {
quantity.value++;
};
const decreaseQuantity = () => {
if (quantity.value > 1) {
quantity.value--;
const updateUserForm = (index, field, value) => {
if (userFormList.value[index]) {
userFormList.value[index][field] = value;
}
};
const handleQuantityInput = (e) => {
const value = parseInt(e.detail.value);
if (value && value > 0) {
quantity.value = value;
} else {
quantity.value = 1;
const deleteUserForm = (index) => {
//
if (userFormList.value.length <= 1) {
uni.showToast({
title: "至少需要一位游客信息",
icon: "none",
duration: 2000,
});
return;
}
// watch
isDeleting.value = true;
//
userFormList.value.splice(index, 1);
// quantity
quantity.value = userFormList.value.length;
};
const confirmOrder = () => {
@ -134,6 +193,7 @@ const confirmOrder = () => {
goodsData: props.goodsData,
quantity: quantity.value,
totalPrice: totalPrice.value,
userFormList: userFormList.value,
};
emits("confirm", orderData);
closePopup();

View File

@ -1,65 +1,61 @@
.good-confirm-container {
background: #fff;
border-radius: 20px 20px 0 0;
padding: 0;
max-height: 80vh;
.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: #e9f3f7;
box-sizing: border-box;
max-height: 60vh;
overflow: hidden;
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
border-bottom: 1px solid #f5f5f5;
position: relative;
.wrapper {
box-sizing: border-box;
padding-left: 12px;
padding-right: 12px;
padding-top: 12px;
}
.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;
}
}
.good-info-wrapper {
border-radius: 12px;
overflow: hidden;
}
.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;
}
}
box-sizing: border-box;
background-color: #fff;
padding: 12px;
.goods-details {
flex: 1;
@ -82,7 +78,7 @@
.goods-price {
display: flex;
align-items: baseline;
margin-bottom: 8px;
margin-bottom: 12px;
.currency {
font-size: 14px;
@ -98,15 +94,38 @@
}
}
.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;
.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;
margin-bottom: 8px;
}
.goods-service-item {
display: flex;
align-items: center;
justify-content: space-between;
.service-label,
.service-value {
font-size: 14px;
font-weight: 500;
}
.service-label {
color: #333;
}
.service-value {
color: #999;
}
}
}
}
@ -115,125 +134,70 @@
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;
}
}
}
box-sizing: border-box;
padding: 12px 0;
}
.total-section {
.user-form-list {
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: #f8f9fa;
flex-direction: row;
white-space: nowrap;
}
}
.total-label {
font-size: 16px;
color: #333;
font-weight: 500;
}
.footer {
margin-top: 12px;
display: flex;
align-items: center;
box-sizing: border-box;
padding-left: 12px;
padding-right: 12px;
padding-bottom: var(--safe-area-inset-bottom);
.total-price {
display: flex;
align-items: baseline;
.left {
display: flex;
align-items: baseline;
}
.currency {
font-size: 16px;
color: #ff6b35;
font-weight: 600;
}
.total-count {
font-size: 12px;
color: #333;
}
.price {
font-size: 24px;
color: #ff6b35;
font-weight: 700;
margin-left: 2px;
}
.total-price {
display: flex;
align-items: baseline;
font-size: 24px;
color: #f55726;
&::before {
content: "¥";
font-size: 12px;
}
}
.footer {
padding: 20px;
background: #fff;
.confirm-btn {
width: 160px;
height: 48px;
background: linear-gradient(179deg, #00a6ff 0%, #0256ff 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;
.confirm-btn {
width: 100%;
height: 48px;
background: linear-gradient(135deg, #ff6b35 0%, #ff8f65 100%);
color: #fff;
&:active {
transform: translateY(1px);
}
&::after {
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

@ -1,16 +1,5 @@
<template>
<view class="good-info">
<!-- 价格区域 -->
<view class="price-section">
<view class="price-main">
<text class="currency">¥</text>
<text class="price">{{ goodsData.price || 399 }}</text>
</view>
<view class="price-tag" v-if="goodsData.tag">
{{ goodsData.tag }}
</view>
</view>
<!-- 标题区域 -->
<view class="title-section">
<text class="title">

View File

@ -2,33 +2,6 @@
background: #fff;
margin-bottom: 12px;
// 价格区域
.price-section {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 12px;
.price-main {
display: flex;
align-items: flex-end;
.currency {
font-size: 12px;
color: #ff6a00;
font-weight: 600;
margin-right: 2px;
}
.price {
font-size: 18px;
color: #ff6a00;
font-weight: 700;
line-height: 1;
}
}
}
// 标题区域
.title-section {
margin-bottom: 12px;

View File

@ -1,9 +1,14 @@
<template>
<view class="goods-container">
<TopNavBar title="商品详情" :fixed="true" />
<TopNavBar title="商品详情" />
<view class="content-wrapper">
<ImageSwiper :border-radius="0" :images="goodsData.commodityPhotoList" />
<!-- 滚动区域 -->
<scroll-view class="content-wrapper" scroll-y>
<ImageSwiper
:border-radius="0"
:height="300"
:images="goodsData.commodityPhotoList"
/>
<view class="goods-content">
<!-- 商品信息组件 -->
@ -12,12 +17,16 @@
<ModuleTitle title="购买须知" />
<zero-markdown-view :markdown="goodsData.commodityTip" :fontSize="14" />
<!-- 立即抢购 -->
<view class="footer">
<button class="buy-button" @click="showConfirmPopup">立即抢购</button>
</view>
</view>
</scroll-view>
<!-- 立即抢购 -->
<view class="footer">
<view class="left">
<text class="label">价格</text>
<text class="price">{{ goodsData.commodityPrice || 399 }}</text>
</view>
<view class="buy-button" @click="showConfirmPopup">立即抢购</view>
</view>
<!-- 商品确认弹窗 -->

View File

@ -2,14 +2,24 @@ $button-color: #00a6ff;
$button-hover-color: darken($button-color, 8%);
.goods-container {
min-height: 100vh;
display: flex;
flex-direction: column;
height: 100vh;
background-color: #fff;
// 顶部导航栏固定样式
:deep(.top-nav-bar) {
position: sticky;
top: 0;
z-index: 100;
flex-shrink: 0;
}
.content-wrapper {
// 为固定导航栏预留空间
padding-top: calc(var(--status-bar-height, 44px) + 68px);
// 为安全区预留空间
padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 100px);
flex: 1;
height: 0; // 关键让flex子项能够正确计算高度
overflow-y: auto;
-webkit-overflow-scrolling: touch; // iOS平滑滚动
}
.goods-content {
@ -20,77 +30,101 @@ $button-hover-color: darken($button-color, 8%);
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);
.footer {
position: sticky;
bottom: 0;
background-color: #fff;
box-sizing: border-box;
padding: 12px;
// 为安全区预留空间
padding-bottom: var(--safe-area-inset-bottom, 0);
// 阴影
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.1);
z-index: 10;
flex-shrink: 0; // 防止被压缩
display: flex;
align-items: center;
.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;
.left {
display: flex;
align-items: baseline;
}
.label {
font-size: 14px;
color: #333;
}
.price {
display: flex;
align-items: baseline;
font-size: 24px;
color: #f55726;
&::before {
content: "¥";
font-size: 12px;
}
}
.buy-button {
width: 160px;
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;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
letter-spacing: 0.5px;
margin-left: auto;
// 按钮波纹效果
&::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 {
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;
width: 300px;
height: 300px;
}
}
&: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);
&:active {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba($button-color, 0.3);
}
&::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);
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba($button-color, 0.3);
}
}
}