bindbox-mini/components/GamePassPurchasePopup.vue
2026-02-03 19:20:31 +08:00

858 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.

<template>
<view>
<view v-if="visible" class="popup-mask" @tap="handleClose">
<view class="popup-content" @tap.stop>
<view class="popup-header">
<text class="title">购买次数卡<text class="title-sub">(次数需使用完剩余次数不可退)</text></text>
<view class="close-btn" @tap="handleClose">×</view>
</view>
<scroll-view scroll-y class="packages-list">
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
<view v-else-if="!packages.length" class="empty-state">
<text>暂无优惠套餐</text>
</view>
<view
v-else
v-for="(pkg, index) in packages"
:key="pkg.id"
class="package-item"
:class="{ 'best-value': pkg.is_best_value, 'selected': selectedPkgId === pkg.id }"
@tap="selectPackage(pkg)"
>
<view class="pkg-tag" v-if="pkg.tag">{{ pkg.tag }}</view>
<view class="pkg-left">
<view class="pkg-name">{{ pkg.name }}</view>
<view class="pkg-count"> {{ pkg.pass_count }} 次游戏</view>
<view class="pkg-validity" v-if="pkg.valid_days > 0">有效期 {{ pkg.valid_days }} </view>
<view class="pkg-validity" v-else>永久有效</view>
</view>
<view class="pkg-right">
<view class="pkg-price-row">
<text class="currency">¥</text>
<text class="price">{{ (getTotalPrice(pkg) / 100).toFixed(2) }}</text>
</view>
<view class="pkg-original-price" v-if="pkg.original_price > pkg.price">
¥{{ (pkg.original_price * (counts[pkg.id] || 1) / 100).toFixed(2) }}
</view>
<view class="action-row">
<view class="stepper" @tap.stop>
<text class="step-btn minus" @tap="updateCount(pkg.id, -1)">-</text>
<input
class="step-input"
type="number"
:value="counts[pkg.id] || 1"
@input="onInputCount(pkg.id, $event)"
@blur="onBlurCount(pkg.id)"
/>
<text class="step-btn plus" @tap="updateCount(pkg.id, 1)">+</text>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 优惠券选择区域 -->
<view class="coupon-section" v-if="selectedPkgId">
<view class="section-title">优惠券</view>
<picker
class="coupon-picker"
mode="selector"
:range="couponOptions"
range-key="displayName"
@change="onCouponChange"
:value="couponIndex"
:disabled="!coupons.length"
>
<view class="picker-display">
<view class="picker-left">
<text v-if="selectedCoupon" class="selected-text">
{{ selectedCoupon.name }}
<text class="discount-amount">(-¥{{ effectiveCouponDiscount.toFixed(2) }})</text>
<text v-if="selectedCoupon.balance_amount > maxDeductible * 100" class="cap-hint">(最高抵扣50%)</text>
</text>
<text v-else-if="!coupons.length" class="placeholder">暂无可用优惠券</text>
<text v-else class="placeholder">请选择优惠券</text>
</view>
<text class="arrow"></text>
</view>
</picker>
</view>
<!-- 底部结算区域 -->
<view class="checkout-section" v-if="selectedPkgId">
<view class="checkout-info">
<view class="checkout-total">
<text class="total-label">合计:</text>
<text class="total-price">¥{{ finalPayAmount.toFixed(2) }}</text>
<text v-if="effectiveCouponDiscount > 0" class="saved-amount">已优惠 ¥{{ effectiveCouponDiscount.toFixed(2) }}</text>
</view>
</view>
<button
class="btn-checkout"
:loading="purchasingId === selectedPkgId"
@tap="handlePurchase"
>
立即购买
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getGamePassPackages, purchaseGamePass, createWechatOrder, getUserCoupons } from '@/api/appUser'
const props = defineProps({
visible: { type: Boolean, default: false },
activityId: { type: [String, Number], default: '' }
})
const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const packages = ref([])
const purchasingId = ref(null)
const counts = ref({})
const selectedPkgId = ref(null)
// 优惠券相关
const coupons = ref([])
const couponIndex = ref(-1)
// 选中的套餐
const selectedPkg = computed(() => {
return packages.value.find(p => p.id === selectedPkgId.value) || null
})
// 计算套餐总价(单价 * 数量)
function getTotalPrice(pkg) {
const count = counts.value[pkg.id] || 1
return pkg.price * count
}
// 当前选中套餐的总价
const currentTotalPrice = computed(() => {
if (!selectedPkg.value) return 0
return getTotalPrice(selectedPkg.value)
})
// 最大可抵扣金额50% 封顶)
const maxDeductible = computed(() => {
return currentTotalPrice.value * 0.5 / 100 // 转换为元
})
// 优惠券选项(添加"不使用"选项)
const couponOptions = computed(() => {
const noCouponOption = { id: 0, displayName: '不使用优惠券', balance_amount: 0 }
return [noCouponOption, ...coupons.value.map(c => ({
...c,
displayName: `${c.name} (余额: ¥${(c.balance_amount / 100).toFixed(2)})`
}))]
})
// 选中的优惠券
const selectedCoupon = computed(() => {
if (couponIndex.value <= 0) return null
return coupons.value[couponIndex.value - 1] || null
})
// 实际优惠金额(考虑 50% 封顶)
const effectiveCouponDiscount = computed(() => {
if (!selectedCoupon.value) return 0
const couponAmt = (selectedCoupon.value.balance_amount || 0) / 100 // 转换为元
return Math.min(couponAmt, maxDeductible.value)
})
// 最终支付金额
const finalPayAmount = computed(() => {
const total = currentTotalPrice.value / 100 // 转换为元
return Math.max(0, total - effectiveCouponDiscount.value)
})
function updateCount(pkgId, delta) {
const current = counts.value[pkgId] || 1
const newVal = current + delta
if (newVal >= 1 && newVal <= 200) {
counts.value[pkgId] = newVal
}
}
function onInputCount(pkgId, e) {
const val = parseInt(e.detail.value) || 1
if (val >= 1 && val <= 200) {
counts.value[pkgId] = val
} else if (val < 1) {
counts.value[pkgId] = 1
} else if (val > 200) {
counts.value[pkgId] = 200
}
}
function onBlurCount(pkgId) {
const current = counts.value[pkgId] || 1
if (current < 1) {
counts.value[pkgId] = 1
} else if (current > 200) {
counts.value[pkgId] = 200
}
}
function selectPackage(pkg) {
selectedPkgId.value = pkg.id
// 选择套餐时重置优惠券选择
couponIndex.value = 0
}
function onCouponChange(e) {
couponIndex.value = e.detail.value
}
watch(() => props.visible, (val) => {
if (val) {
fetchPackages()
fetchCoupons()
} else {
// 关闭时重置状态
selectedPkgId.value = null
couponIndex.value = 0
}
})
async function fetchPackages() {
loading.value = true
try {
const res = await getGamePassPackages(props.activityId)
let list = []
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.packages)) list = res.packages
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
const countMap = {}
list.forEach(p => countMap[p.id] = 1)
counts.value = countMap
packages.value = list.map(p => {
let tag = ''
const discount = 1 - (p.price / p.original_price)
if (p.original_price > 0 && discount >= 0.2) {
tag = `${Math.floor(discount * 100)}%`
}
return { ...p, tag }
})
// 默认选中第一个套餐
if (packages.value.length > 0) {
selectedPkgId.value = packages.value[0].id
}
} catch (e) {
console.error(e)
packages.value = []
} finally {
loading.value = false
}
}
async function fetchCoupons() {
try {
const userId = uni.getStorageSync('user_id')
if (!userId) return
const res = await getUserCoupons(userId, 0, 1, 20) // status=0 可用
let list = []
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.coupons)) list = res.coupons
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
// 过滤出全场通用券 (scope_type = 1) 和金额券 (discount_type = 1)
// 后端已限制次卡购买只能用全场券,这里前端也做一层过滤
// 同时支持 balance_amount 和 remaining 字段
coupons.value = list.filter(c => {
// 只显示 scope_type=1全场通用的优惠券
// 如果后端返回了 scope_type 字段
if (c.scope_type !== undefined && c.scope_type !== 1) return false
// 确保有余额(兼容 balance_amount 和 remaining 字段)
const balance = c.balance_amount ?? c.remaining ?? c.amount
if (!balance || balance <= 0) return false
return true
}).map(c => ({
// 统一转换为 balance_amount 字段供后续使用
...c,
balance_amount: c.balance_amount ?? c.remaining ?? c.amount
}))
console.log('获取到的优惠券列表:', coupons.value)
} catch (e) {
console.error('获取优惠券失败:', e)
coupons.value = []
}
}
async function handlePurchase() {
if (!selectedPkgId.value) {
uni.showToast({ title: '请选择套餐', icon: 'none' })
return
}
const pkg = selectedPkg.value
if (!pkg) return
if (purchasingId.value) return
purchasingId.value = pkg.id
try {
uni.showLoading({ title: '创建订单...' })
const count = counts.value[pkg.id] || 1
const couponIds = selectedCoupon.value ? [selectedCoupon.value.id] : []
const res = await purchaseGamePass(pkg.id, count, couponIds)
const orderNo = res.order_no || res.orderNo
// 0 元订单直接成功
if (finalPayAmount.value <= 0) {
uni.showToast({ title: '购买成功', icon: 'success' })
emit('success')
handleClose()
return
}
if (!orderNo) throw new Error('下单失败')
// 拉起支付
const openid = uni.getStorageSync('openid')
const payRes = await createWechatOrder({ openid, order_no: orderNo })
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'RSA',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
uni.showToast({ title: '购买成功', icon: 'success' })
emit('success')
handleClose()
} catch (e) {
if (e?.errMsg && e.errMsg.includes('cancel')) {
uni.showToast({ title: '取消支付', icon: 'none' })
} else {
uni.showToast({ title: e.message || '购买失败', icon: 'none' })
}
} finally {
uni.hideLoading()
purchasingId.value = null
}
}
function handleClose() {
emit('update:visible', false)
}
</script>
<style lang="scss" scoped>
.popup-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(10rpx);
z-index: 999;
display: flex;
align-items: flex-end;
animation: fadeIn 0.25s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.popup-content {
width: 100%;
background: linear-gradient(180deg, #FFFFFF 0%, #FAFBFF 100%);
border-radius: 40rpx 40rpx 0 0;
box-shadow: 0 -8rpx 40rpx rgba(0, 0, 0, 0.12);
padding-bottom: env(safe-area-inset-bottom);
max-height: 82vh;
display: flex;
flex-direction: column;
animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.popup-header {
padding: 40rpx 32rpx 24rpx;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2rpx solid #F0F2F5;
background: linear-gradient(180deg, #FFFFFF 0%, #FAFBFF 100%);
position: relative;
&::after {
content: '';
position: absolute;
bottom: -8rpx;
left: 50%;
transform: translateX(-50%);
width: 80rpx;
height: 6rpx;
background: #E5E7EB;
border-radius: 3rpx;
}
.title {
font-size: 38rpx;
font-weight: 800;
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 0.5rpx;
}
.title-sub {
font-size: 22rpx;
font-weight: 400;
color: #9CA3AF;
margin-left: 8rpx;
-webkit-text-fill-color: #9CA3AF;
}
.close-btn {
font-size: 52rpx;
color: #CBD5E1;
line-height: 0.8;
padding: 8rpx;
font-weight: 200;
transition: all 0.2s ease;
&:active {
color: #667EEA;
transform: rotate(90deg);
}
}
}
.packages-list {
padding: 32rpx 24rpx 16rpx;
max-height: 42vh;
flex-shrink: 0;
}
.loading-state, .empty-state {
text-align: center;
padding: 80rpx 0;
color: #9CA3AF;
font-size: 28rpx;
font-weight: 500;
&::before {
content: '📦';
display: block;
font-size: 88rpx;
margin-bottom: 16rpx;
opacity: 0.4;
}
}
.package-item {
position: relative;
background: linear-gradient(145deg, #FFFFFF 0%, #F8F9FF 100%);
border: 2rpx solid #E8EEFF;
border-radius: 28rpx;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.08);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 6rpx;
background: linear-gradient(90deg, #667EEA 0%, #764BA2 100%);
opacity: 0;
transition: opacity 0.3s;
}
&:active {
transform: scale(0.97);
box-shadow: 0 2rpx 12rpx rgba(102, 126, 234, 0.12);
}
&:active::before {
opacity: 1;
}
}
.pkg-tag {
position: absolute;
top: 0;
left: 0;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: #FFF;
font-size: 20rpx;
padding: 6rpx 16rpx;
border-bottom-right-radius: 16rpx;
font-weight: 700;
letter-spacing: 0.5rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 107, 0.3);
z-index: 1;
}
.pkg-left {
flex: 1;
padding-right: 16rpx;
}
.pkg-name {
font-size: 34rpx;
font-weight: 700;
color: #1F2937;
margin-bottom: 10rpx;
letter-spacing: 0.3rpx;
}
.pkg-count {
font-size: 26rpx;
color: #6B7280;
margin-bottom: 6rpx;
font-weight: 500;
display: flex;
align-items: center;
&::before {
content: '🎮';
font-size: 22rpx;
margin-right: 6rpx;
}
}
.pkg-validity {
font-size: 22rpx;
color: #9CA3AF;
font-weight: 400;
display: flex;
align-items: center;
&::before {
content: '⏰';
font-size: 18rpx;
margin-right: 4rpx;
}
}
.pkg-right {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 200rpx;
}
.pkg-price-row {
color: #FF6B6B;
font-weight: 800;
margin-bottom: 6rpx;
display: flex;
align-items: baseline;
letter-spacing: -0.5rpx;
.currency {
font-size: 26rpx;
font-weight: 700;
margin-right: 2rpx;
}
.price {
font-size: 44rpx;
text-shadow: 0 2rpx 8rpx rgba(255, 107, 107, 0.15);
}
}
.pkg-original-price {
font-size: 22rpx;
color: #CBD5E1;
text-decoration: line-through;
margin-bottom: 10rpx;
font-weight: 500;
}
.btn-buy {
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
color: #FFF;
font-size: 26rpx;
padding: 0 28rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
border: none;
font-weight: 700;
box-shadow: 0 6rpx 20rpx rgba(102, 126, 234, 0.35);
transition: all 0.3s ease;
letter-spacing: 0.5rpx;
&[loading] {
opacity: 0.8;
transform: scale(0.95);
}
&:active {
transform: scale(0.92);
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.4);
}
&::after {
border: none;
}
}
.action-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 8rpx;
}
.stepper {
display: flex;
align-items: center;
background: linear-gradient(145deg, #F1F3F9 0%, #E8EEFF 100%);
border: 2rpx solid #E0E7FF;
border-radius: 16rpx;
padding: 4rpx;
box-shadow: inset 0 2rpx 6rpx rgba(102, 126, 234, 0.06);
.step-btn {
width: 48rpx;
height: 48rpx;
line-height: 44rpx;
text-align: center;
font-size: 32rpx;
color: #667EEA;
font-weight: 600;
flex-shrink: 0;
transition: all 0.2s ease;
border-radius: 12rpx;
&:active {
background: rgba(102, 126, 234, 0.1);
transform: scale(0.9);
}
}
.minus {
color: #9CA3AF;
&:active {
color: #667EEA;
background: rgba(102, 126, 234, 0.1);
}
}
.step-input {
width: 64rpx;
height: 48rpx;
line-height: 48rpx;
text-align: center;
font-size: 28rpx;
font-weight: 700;
color: #1F2937;
background: transparent;
border: none;
padding: 0;
margin: 0;
&::placeholder {
color: #CBD5E1;
}
}
}
// 选中套餐状态
.package-item.selected {
border-color: #667EEA;
background: linear-gradient(145deg, #F8F9FF 0%, #EEF0FF 100%);
box-shadow: 0 4rpx 20rpx rgba(102, 126, 234, 0.2);
&::before {
opacity: 1;
}
}
// 优惠券选择区域
.coupon-section {
padding: 0 24rpx 20rpx;
border-top: 2rpx solid #F0F2F5;
margin-top: 0;
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #374151;
margin-bottom: 16rpx;
padding-top: 20rpx;
}
.coupon-picker {
width: 100%;
}
.picker-display {
background: linear-gradient(145deg, #F8F9FF 0%, #FFFFFF 100%);
border: 2rpx solid #E8EEFF;
border-radius: 16rpx;
padding: 20rpx 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
&:active {
border-color: #667EEA;
background: #F0F3FF;
}
}
.picker-left {
flex: 1;
overflow: hidden;
}
.selected-text {
font-size: 28rpx;
color: #1F2937;
font-weight: 500;
.discount-amount {
color: #10B981;
font-weight: 600;
margin-left: 8rpx;
}
.cap-hint {
font-size: 22rpx;
color: #F59E0B;
margin-left: 8rpx;
}
}
.placeholder {
font-size: 28rpx;
color: #9CA3AF;
}
.arrow {
width: 16rpx;
height: 16rpx;
border-right: 3rpx solid #9CA3AF;
border-bottom: 3rpx solid #9CA3AF;
transform: rotate(-45deg);
margin-left: 16rpx;
flex-shrink: 0;
}
}
// 底部结算区域
.checkout-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, #FFFFFF 0%, #F8F9FF 100%);
border-top: 2rpx solid #F0F2F5;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
.checkout-info {
flex: 1;
}
.checkout-total {
display: flex;
align-items: baseline;
flex-wrap: wrap;
}
.total-label {
font-size: 28rpx;
color: #6B7280;
margin-right: 8rpx;
}
.total-price {
font-size: 48rpx;
font-weight: 800;
color: #FF6B6B;
letter-spacing: -1rpx;
}
.saved-amount {
font-size: 22rpx;
color: #10B981;
background: rgba(16, 185, 129, 0.1);
padding: 4rpx 12rpx;
border-radius: 20rpx;
margin-left: 12rpx;
font-weight: 500;
}
.btn-checkout {
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
color: #FFF;
font-size: 32rpx;
padding: 0 48rpx;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
border: none;
font-weight: 700;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4);
transition: all 0.3s ease;
letter-spacing: 1rpx;
&[loading] {
opacity: 0.8;
}
&:active {
transform: scale(0.95);
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.5);
}
&::after {
border: none;
}
}
}
</style>