bindbox-mini/components/GamePassPurchasePopup.vue

543 lines
13 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 }"
@tap="handlePurchase(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">{{ (pkg.price / 100).toFixed(2) }}</text>
</view>
<view class="pkg-original-price" v-if="pkg.original_price > pkg.price">
¥{{ (pkg.original_price / 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>
<button class="btn-buy" :loading="purchasingId === pkg.id" @tap.stop="handlePurchase(pkg)">
购买
</button>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
import { getGamePassPackages, purchaseGamePass, createWechatOrder } 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({})
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
}
}
watch(() => props.visible, (val) => {
if (val) {
fetchPackages()
}
})
async function fetchPackages() {
loading.value = true
try {
const res = await getGamePassPackages(props.activityId)
// res 应该是数组
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
// 初始化counts
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 }
})
} catch (e) {
console.error(e)
packages.value = []
} finally {
loading.value = false
}
}
async function handlePurchase(pkg) {
if (purchasingId.value) return
purchasingId.value = pkg.id
try {
uni.showLoading({ title: '创建订单...' })
// 1. 调用购买接口 (后端创建订单 + 预下单)
// 注意根据后端实现purchaseGamePass 可能直接返回支付参数,或者需要我们自己调 createWechatOrder
// 之前分析 game_passes_app.go它似乎返回的是 simple success?
// 让我们再确认一下 game_passes_app.go 的 PurchaseGamePassPackage
// 既然我看不到代码,按常规逻辑:
// 如果返回 order_no则需要 createWechatOrder
// 如果返回 pay_params则直接支付
// 假设 API 返回 { order_no, ... }
const count = counts.value[pkg.id] || 1
const res = await purchaseGamePass(pkg.id, count)
const orderNo = res.order_no || res.orderNo
if (!orderNo) throw new Error('下单失败')
// 2. 拉起支付
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;
max-height: 62vh;
}
.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;
}
}
}
</style>