Files
xdd-uniapp-new/pkg_product/views/experienceCoupon.vue
T

814 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import dayjs from 'dayjs'
import { getLatestExperienceCoupon, getExperienceCouponList, getExperienceCouponItems, createExperienceCouponOrder } from '@/api/product'
import { getExperienceStoreCategoryList } from '@/api/join'
export default {
data() {
const webUrl = this.$VUE_APP_RESOURCES_URL
return {
webUrl,
headerImage: webUrl + '/experienceCoupon/header.png',
totalCount: 0,
selectedCount: 0,
initialUsedCount: 0,
remainNum: 0,
startTime: '',
endTime: '',
expireDate: '',
couponList: [],
activeCouponId: null,
categoryList: [
{ id: 'all', name: '全部体验' }
],
activeCategoryId: 'all',
experienceList: [],
userLatitude: null,
userLongitude: null
}
},
computed: {
filteredList() {
if (this.activeCategoryId === 'all') {
return this.experienceList
}
return this.experienceList.filter(item => item.categoryId === this.activeCategoryId)
},
remainCount() {
return Math.max(this.totalCount - this.selectedCount, 0)
}
},
onLoad() {
this.initPage()
},
methods: {
formatDateByTimestamp(value) {
if (value == null || value === '') {
return ''
}
if (typeof value === 'number') {
const ts = value > 1000000000000 ? value : value * 1000
return dayjs(ts).format('YYYY-MM-DD')
}
const num = Number(value)
if (!Number.isNaN(num)) {
const ts = num > 1000000000000 ? num : num * 1000
return dayjs(ts).format('YYYY-MM-DD')
}
return value
},
initPage() {
this.getUserLocation()
this.fetchExperienceCoupon()
this.fetchCouponList()
this.fetchCategoryList()
},
getUserLocation() {
uni.getLocation({
type: 'gcj02',
success: (res) => {
this.userLatitude = res.latitude
this.userLongitude = res.longitude
this.updateDistances()
},
fail: (err) => {
console.error('获取位置失败', err)
}
})
},
updateDistances() {
if (!this.experienceList.length || !this.userLatitude || !this.userLongitude) {
return
}
this.experienceList = this.experienceList.map(item => {
if (item.latitude && item.longitude) {
return {
...item,
distance: this.calculateDistance(this.userLatitude, this.userLongitude, item.latitude, item.longitude)
}
}
return item
})
},
calculateDistance(lat1, lon1, lat2, lon2) {
if (!lat1 || !lon1 || !lat2 || !lon2) return ''
const R = 6371 // 地球半径 km
const dLat = (lat2 - lat1) * Math.PI / 180
const dLon = (lon2 - lon1) * Math.PI / 180
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
const d = R * c
return d.toFixed(1) + 'km'
},
setActiveCoupon(coupon) {
if (!coupon) {
return
}
this.activeCouponId = coupon.id
this.totalCount = coupon.totalNum || 0
const remain = coupon.remainNum != null ? coupon.remainNum : 0
this.remainNum = remain
this.initialUsedCount = Math.max(this.totalCount - remain, 0)
this.selectedCount = this.initialUsedCount
this.startTime = coupon.startTime || ''
this.endTime = coupon.endTime || ''
this.expireDate = this.endTime || this.expireDate
this.headerImage = coupon.experienceCouponImage || (this.webUrl + '/experienceCoupon/header.png')
this.fetchExperienceItems()
},
fetchExperienceCoupon() {
getLatestExperienceCoupon().then(({ data }) => {
if (!data) {
return
}
const latest = {
...data,
startTime: this.formatDateByTimestamp(data.startTime),
endTime: this.formatDateByTimestamp(data.endTime)
}
this.setActiveCoupon(latest)
if (!this.couponList.length) {
this.couponList = [latest]
}
}).catch(() => {})
},
fetchCouponList() {
getExperienceCouponList().then(({ data }) => {
const list = (data || []).map(item => ({
...item,
startTime: this.formatDateByTimestamp(item.startTime),
endTime: this.formatDateByTimestamp(item.endTime)
}))
this.couponList = list
if (!this.activeCouponId && list.length > 0) {
this.setActiveCoupon(list[0])
}
}).catch(() => {})
},
fetchCategoryList() {
getExperienceStoreCategoryList().then(({ data }) => {
const list = data || []
this.categoryList = [
{ id: 'all', name: '全部体验' },
...list.map(item => ({
id: item.id,
name: item.name
}))
]
}).catch(() => {})
},
fetchExperienceItems() {
if (!this.activeCouponId) {
this.experienceList = []
return
}
const params = {
couponId: this.activeCouponId
}
if (this.activeCategoryId && this.activeCategoryId !== 'all') {
params.experienceStoreCategoryId = this.activeCategoryId
}
getExperienceCouponItems(params).then(({ data }) => {
const list = data || []
this.experienceList = list.map(item => {
const lat = Number(item.latitude || 0)
const lon = Number(item.longitude || 0)
let distance = ''
if (this.userLatitude && this.userLongitude && lat && lon) {
distance = this.calculateDistance(this.userLatitude, this.userLongitude, lat, lon)
}
return {
id: item.id != null ? item.id : (item.projectId != null ? item.projectId : item.merId),
title: item.name || '',
subTitle: item.projectName || item.intro || '',
distance,
latitude: lat,
longitude: lon,
businessTime: item.serviceTime && item.serviceEndTime ? `${item.serviceTime}-${item.serviceEndTime}` : (item.serviceTime || ''),
tags: item.intro || '',
categoryId: this.activeCategoryId === 'all' ? 'all' : this.activeCategoryId,
image: item.image || item.cover || '',
selected: false
}
})
}).catch(() => {
this.experienceList = []
})
},
changeCoupon(coupon) {
this.setActiveCoupon(coupon)
},
changeCategory(id) {
this.activeCategoryId = id
this.fetchExperienceItems()
},
toggleSelect(item) {
if (!item.selected && this.remainCount === 0) {
uni.showToast({
title: '体验券数量已达上限',
icon: 'none'
})
return
}
item.selected = !item.selected
const currentSelected = this.experienceList.filter(it => it.selected).length
this.selectedCount = this.initialUsedCount + currentSelected
},
submitOrder() {
if (!this.activeCouponId) {
uni.showToast({
title: '当前无可用体验券',
icon: 'none'
})
return
}
const selected = this.experienceList.filter(it => it.selected)
if (!selected.length) {
uni.showToast({
title: '请先选择体验项目',
icon: 'none'
})
return
}
const projectIds = selected.map(it => it.id)
const data = {
couponId: this.activeCouponId,
projectIds
}
createExperienceCouponOrder(data).then((res) => {
if (res.success || (res.data && res.data.status === 'SUCCESS')) {
uni.showToast({
title: '下单成功',
icon: 'success'
})
setTimeout(() => {
const orderId = res.data && res.data.orderId ? res.data.orderId : (res.orderId || '')
if (orderId) {
uni.navigateTo({
url: `/pkg_product/views/experienceCouponOrderDetail?orderId=${orderId}`
})
} else {
this.initPage()
}
}, 1500)
} else {
uni.showToast({
title: res.msg || '下单失败,请稍后重试',
icon: 'none'
})
}
}).catch(() => {
uni.showToast({
title: '下单失败,请稍后重试',
icon: 'none'
})
})
},
handleContinueSelect() {
this.submitOrder()
},
handleGoExperience() {
this.submitOrder()
},
handleConfirm() {
this.submitOrder()
}
}
}
</script>
<template>
<view class="experience-coupon-page">
<view class="header">
<image
:src="headerImage"
class="header-bg"
mode="aspectFill"
/>
<view class="header-mask" />
<view class="header-blur-transition" />
<view class="header-content">
<scroll-view
v-if="couponList.length > 1"
scroll-x
class="coupon-tabs-scroll"
:show-scrollbar="false"
>
<view class="coupon-tabs">
<view
v-for="(coupon, index) in couponList"
:key="coupon.id"
class="header-tag-main"
:class="{ active: activeCouponId === coupon.id }"
@click="changeCoupon(coupon)"
>
体验券{{ index + 1}}
</view>
</view>
</scroll-view>
<view class="header-main">
<view class="line-info flex ai-center">
根据您的套餐您有 <view class="num-badge">{{ totalCount }}</view> 张体验券可以选择
</view>
<view class="line-status flex ai-center">
<view class="status-pill flex ai-center">
<u-icon name="checkmark-circle" color="#fff" size="14" style="margin-left: 6rpx;" />
<text class="status-text">已选清单</text>
<view class="count-badge">{{ selectedCount }}/{{ totalCount }}</view>
</view>
<view class="date-info flex ai-center">
<text>请在</text>
<view class="date-badge">{{ expireDate }}</view>
<text>内选择使用</text>
</view>
</view>
<view class="line-library flex ai-center">
<image src="https://img.js.design/assets/img/6784c04289050d2f0997e096.png" class="icon-shop" mode="widthFix" />
<text class="v12-font-32">可用体验券库</text>
</view>
</view>
</view>
</view>
<view class="content">
<scroll-view scroll-x class="tabs-scroll" :show-scrollbar="false">
<view class="tabs flex">
<view
v-for="item in categoryList"
:key="item.id"
class="tab-item"
:class="{ active: activeCategoryId === item.id }"
@click="changeCategory(item.id)"
>
{{ item.name }}
</view>
</view>
</scroll-view>
<view
v-if="filteredList.length === 0"
class="empty-state"
>
<image
:src="webUrl + '/orderIcon/nodata.png'"
class="empty-image"
mode="widthFix"
/>
<view class="empty-text">
暂无数据
</view>
</view>
<view
v-else
class="card-list"
>
<view
v-for="item in filteredList"
:key="item.id"
class="card"
>
<image
:src="item.image"
class="card-image"
mode="widthFix"
/>
<view class="card-body">
<view class="card-title">
{{ item.title }}
</view>
<view class="info-row flex jc-between ai-center">
<view class="card-subtitle more-t">
{{ item.subTitle }}
</view>
<view class="meta-distance" v-if="item.distance">
<u-icon name="map" color="#999" size="14" style="margin-right: 4rpx;" />
<text>距该地{{ item.distance }}</text>
</view>
</view>
<view class="card-meta">
<text class="meta-time">营业时间{{ item.businessTime }}</text>
</view>
<view class="card-footer flex jc-center ai-center">
<view
class="card-btn"
:class="{ selected: item.selected }"
@click.stop="toggleSelect(item)"
>
{{ item.selected ? '取消选择' : '选择' }}
<view v-if="item.selected" class="check-icon-wrapper">
<u-icon name="checkmark" color="#C52733" size="14" bold />
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="bottom-bar" v-if="selectedCount > 0">
<view
v-if="remainCount > 0"
class="bottom-group flex jc-between ai-center"
>
<view
class="btn-outline"
>
继续选择还可以选{{ remainCount }}
</view>
<view
class="btn-primary"
@click="handleGoExperience"
>
先去体验
</view>
</view>
<view
v-else
class="bottom-single"
>
<view
class="btn-primary"
@click="handleConfirm"
>
确认
</view>
</view>
</view>
</view>
</template>
<style scoped lang="less">
.experience-coupon-page {
min-height: 100vh;
padding-bottom: 140rpx;
background: #f5f5f5;
color: #333;
}
.header {
position: relative;
height: 500rpx;
overflow: hidden;
}
.header-bg {
width: 100%;
height: 100%;
}
.header-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.6) 100%);
z-index: 1;
}
.header-blur-transition {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 200rpx; /* 增加过渡高度让渐变更平缓 */
background: linear-gradient(to bottom, rgba(245, 245, 245, 0) 0%, rgba(245, 245, 245, 0.4) 40%, rgba(245, 245, 245, 0.8) 70%, #f5f5f5 100%);
pointer-events: none;
z-index: 2;
}
.header-content {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
padding: 32rpx;
padding-bottom: 120rpx; /* 为下方内容上移留出空间 */
box-sizing: border-box;
color: #fff;
display: flex;
flex-direction: column;
justify-content: space-between;
z-index: 3;
}
.header-tag-main {
align-self: flex-start;
padding: 12rpx 32rpx 12rpx 24rpx;
background: #fff;
font-weight: bold;
color: #333;
font-size: 28rpx;
border-radius: 40rpx;
flex-shrink: 0;
}
.header-tag-main.active {
background: #C52733;
color: #fff;
}
.header-main {
margin-bottom: 20rpx;
}
.coupon-tabs-scroll {
width: 100%;
white-space: nowrap;
margin-bottom: 20rpx;
}
.coupon-tabs {
display: flex;
gap: 16rpx;
padding-right: 24rpx;
flex-wrap: nowrap;
}
.coupon-tab-item {
padding: 8rpx 24rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.2);
border: 1rpx solid rgba(255, 255, 255, 0.4);
font-size: 24rpx;
color: #fff;
&.active {
background: #fff;
color: #C52733;
border-color: #fff;
}
}
.line-info {
font-size: 26rpx;
margin-bottom: 24rpx;
justify-content: flex-end;
}
.num-badge {
background: #C52733;
color: #fff;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
margin: 0 8rpx;
font-weight: bold;
}
.line-status {
justify-content: flex-end;
gap: 16rpx;
margin-bottom: 24rpx;
}
.status-pill {
background: #C52733;
padding: 6rpx 6rpx 6rpx 20rpx;
border-radius: 32rpx;
color: #fff;
font-size: 26rpx;
}
.status-text {
margin-right: 12rpx;
}
.icon-check {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
border: 2rpx solid #fff;
margin-right: 8rpx;
position: relative;
}
.icon-check::after {
content: '';
position: absolute;
left: 6rpx;
top: 4rpx;
width: 8rpx;
height: 4rpx;
border-left: 2rpx solid #fff;
border-bottom: 2rpx solid #fff;
transform: rotate(-45deg);
}
.count-badge {
background: #fff;
color: #333;
padding: 4rpx 16rpx;
border-radius: 24rpx;
font-size: 24rpx;
}
.date-info {
font-size: 26rpx;
color: #fff;
}
.date-badge {
background: #C52733;
padding: 6rpx 16rpx;
border-radius: 100rpx;
margin: 0 8rpx;
font-size: 32rpx;
font-weight: bold;
}
.line-library {
font-size: 28rpx;
font-weight: 500;
.icon-shop {
width: 32rpx;
height: 32rpx;
margin-right: 8rpx;
}
}
.content {
padding: 0 24rpx;
margin-top: -120rpx; /* 将下方内容整体上移,覆盖在渐变区域上 */
position: relative;
z-index: 4;
}
.tabs-scroll {
width: 100%;
white-space: nowrap;
margin-bottom: 24rpx;
}
.tabs {
display: flex;
gap: 16rpx;
padding-right: 24rpx;
}
.tab-item {
padding: 12rpx 32rpx;
border-radius: 32rpx;
background: #fff;
color: #666;
font-size: 26rpx;
flex-shrink: 0;
font-style: italic;
font-weight: bold;
&.active {
background: #C52733;
color: #fff;
}
}
.card-list {
padding-bottom: 24rpx;
column-count: 2;
column-gap: 20rpx;
}
.card {
margin-bottom: 24rpx;
border-radius: 20rpx;
overflow: hidden;
background: #fff;
break-inside: avoid;
}
.card-image {
width: 100%;
height: auto;
display: block;
}
.card-body {
padding: 16rpx;
}
.card-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.card-subtitle {
font-size: 22rpx;
color: #999;
flex: 1;
min-width: 0;
margin-right: 16rpx;
}
.info-row {
margin-bottom: 8rpx;
}
.card-meta {
font-size: 22rpx;
margin-bottom: 16rpx;
}
.meta-distance {
display: flex;
align-items: center;
color: #999;
flex-shrink: 0;
font-size: 22rpx;
}
.icon-location {
width: 20rpx;
height: 20rpx;
border: 2rpx solid #666;
border-radius: 50%;
margin-right: 4rpx;
position: relative;
}
.icon-location::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 6rpx;
height: 6rpx;
background: #666;
border-radius: 50%;
}
.meta-time {
display: block;
color: #333;
font-weight: 500;
}
.card-footer {
margin-top: 12rpx;
}
.card-btn {
width: 100%;
height: 64rpx;
border-radius: 32rpx;
font-size: 26rpx;
border: 1rpx solid #C52733;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
position: relative;
background: #C52733;
color: #fff;
}
.check-icon-wrapper {
position: absolute;
right: -8rpx;
top: -8rpx;
background: #fff;
border-radius: 50%;
width: 28rpx;
height: 28rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
/* 缺口在图标的左下角方向 (225deg左右设为透明) */
background: linear-gradient(#fff, #fff) padding-box,
conic-gradient(from 225deg, transparent 0deg 90deg, #C52733 90deg 360deg) border-box;
border: 2rpx solid transparent;
box-shadow: 0 0 0 2rpx #fff; /* 外层白边 */
}
/* 移除之前的伪元素遮挡方案 */
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 24rpx 32rpx 48rpx;
background: #fff;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);
z-index: 10;
}
.bottom-group {
gap: 24rpx;
}
.btn-outline {
flex: 1.2;
height: 88rpx;
border-radius: 44rpx;
border: 2rpx solid #333;
text-align: center;
font-size: 28rpx;
line-height: 84rpx;
color: #333;
background: #fff;
font-weight: bold;
font-style: italic;
}
.btn-primary {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
text-align: center;
font-size: 30rpx;
line-height: 88rpx;
color: #fff;
background: #C52733;
font-weight: bold;
font-style: italic;
}
.bottom-single .btn-primary {
width: 100%;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0;
}
.empty-image {
width: 320rpx;
height: 320rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
margin-top: 20rpx;
}
</style>