bindbox-mini/components/PaymentPopup.vue

935 lines
22 KiB
Vue
Raw Permalink 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="showBlessing" class="blessing-container">
<view class="blessing-animation" :class="currentBlessing.type">
<view class="blessing-emoji">{{ currentBlessing.emoji }}</view>
<view v-if="currentBlessing.type === 'sheep'" class="blessing-subtitle">小羊祝你</view>
<view class="blessing-text">
<text v-for="(char, index) in currentBlessing.chars"
:key="index"
class="char"
:class="{ 'from-left': index % 2 === 0, 'from-right': index % 2 === 1 }"
:style="{ animationDelay: index * 0.15 + 's' }">
{{ char }}
</text>
</view>
</view>
</view>
<!-- 支付弹窗 -->
<view v-if="visible" class="payment-popup-mask" @tap="handleMaskClick">
<view class="payment-popup-content" @tap.stop>
<!-- 顶部提示 -->
<view class="risk-warning">
<text>盲盒具有随机性请理性消费购买即表示同意</text>
<text class="agreement-link" @tap="openAgreement">购买协议</text>
</view>
<view class="popup-header">
<text class="popup-title">确认支付</text>
<view class="close-icon" @tap="handleClose">×</view>
</view>
<view class="popup-body">
<!-- 次数卡选项有数据时显示 -->
<view v-if="gamePasses" class="game-pass-section">
<view
class="game-pass-option"
:class="{ active: useGamePass, disabled: gamePassRemaining <= 0 }"
@tap="gamePassRemaining > 0 ? toggleGamePass() : null"
>
<view class="game-pass-radio">
<view v-if="useGamePass" class="radio-checked"></view>
<view v-else-if="gamePassRemaining <= 0" class="radio-disabled" />
</view>
<view class="game-pass-info">
<text class="game-pass-label" :class="{ 'text-disabled': gamePassRemaining <= 0 }">剩余次数</text>
<text class="game-pass-count" v-if="gamePassRemaining > 0">{{ gamePassRemaining }} </text>
<text class="game-pass-count text-disabled" v-else>暂无可用次数卡</text>
</view>
</view>
<view v-if="!useGamePass" class="divider-line">
<text class="divider-text">或选择其他支付方式</text>
</view>
</view>
<view class="amount-section" v-if="!useGamePass && amount !== undefined && amount !== null">
<text class="label">支付金额</text>
<text class="amount">¥{{ finalPayAmount }}</text>
<text v-if="finalPayAmount < amount" class="original-amount" style="text-decoration: line-through; color: #999; font-size: 24rpx; margin-left: 10rpx;">¥{{ amount }}</text>
</view>
<view class="form-item">
<text class="label">优惠券</text>
<picker
class="picker-full"
mode="selector"
:range="coupons"
range-key="name"
@change="onCouponChange"
:value="couponIndex"
:disabled="(!coupons || coupons.length === 0) || useGamePass"
>
<view class="picker-display" :class="{ 'picker-disabled': useGamePass }">
<text v-if="useGamePass" class="placeholder" style="color: #666;">
多次卡不可与优惠券同享
</text>
<text v-if="selectedCoupon" class="selected-text">
{{ selectedCoupon.name }} (-¥{{ effectiveCouponDiscount.toFixed(2) }})
<text v-if="selectedCoupon.amount > maxDeductible" style="font-size: 20rpx; color: #FF9800;">(最高抵扣50%)</text>
</text>
<text v-else-if="!coupons || coupons.length === 0" class="placeholder">暂无优惠券可用</text>
<text v-else class="placeholder">请选择优惠券</text>
<text class="arrow"></text>
</view>
</picker>
</view>
<view class="form-item" v-if="showCards">
<text class="label">道具卡</text>
<picker
class="picker-full"
mode="selector"
:range="displayCards"
range-key="displayName"
@change="onCardChange"
:value="cardIndex"
:disabled="!displayCards || displayCards.length === 0"
>
<view class="picker-display">
<text v-if="selectedCard" class="selected-text">
{{ selectedCard.name }}
<text v-if="Number(selectedCard.count) > 1" style="color: #999; font-size: 24rpx; margin-left: 6rpx;">(拥有: {{ selectedCard.count }})</text>
</text>
<text v-else-if="!displayCards || displayCards.length === 0" class="placeholder">暂无道具卡可用</text>
<text v-else class="placeholder">请选择道具卡</text>
<text class="arrow"></text>
</view>
</picker>
</view>
</view>
<view class="popup-footer">
<button class="btn-cancel" @tap="handleClose">取消</button>
<button v-if="useGamePass" class="btn-confirm btn-game-pass" @tap="handleConfirm">🎮 使用次数卡</button>
<button v-else class="btn-confirm" @tap="handleConfirm">确认支付</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
amount: { type: [Number, String], default: 0 },
coupons: { type: Array, default: () => [] },
propCards: { type: Array, default: () => [] },
showCards: { type: Boolean, default: true },
gamePasses: { type: Object, default: () => null } // { total_remaining, passes }
})
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
// 祝福动画相关
const showBlessing = ref(false)
const blessings = [
{
emoji: '🐏',
chars: ['三', '羊', '开', '泰'],
type: 'sheep'
},
{
emoji: '🐴',
chars: ['一', '马', '当', '先'],
type: 'horse'
},
{
emoji: '🍊',
chars: ['心', '想', '事', '橙'],
type: 'orange'
},
{
emoji: '🐵',
chars: ['财', '源', '广', '进'],
type: 'monkey'
},
{
emoji: '🐮',
chars: ['牛', '气', '冲', '天'],
type: 'ox'
},
{
emoji: '🐶',
chars: ['旺', '旺', '旺', '旺'],
type: 'dog'
},
{
emoji: '🐔',
chars: ['吉', '祥', '如', '意'],
type: 'chicken'
}
]
const currentBlessing = ref(blessings[0])
// 监听弹窗打开,显示祝福动画
watch(() => props.visible, (newVal) => {
console.log('[PaymentPopup] visible changed:', newVal)
if (newVal) {
// 随机选择祝福语
const index = Math.floor(Math.random() * blessings.length)
currentBlessing.value = blessings[index]
// 延迟显示祝福动画
setTimeout(() => {
console.log('[PaymentPopup] 显示祝福动画')
showBlessing.value = true
// 3秒后隐藏祝福动画
setTimeout(() => {
showBlessing.value = false
console.log('[PaymentPopup] 隐藏祝福动画')
}, 3000)
}, 300) // 延迟300ms让支付弹窗先滑入
} else {
showBlessing.value = false
}
})
const couponIndex = ref(-1)
const cardIndex = ref(-1)
const useGamePass = ref(false)
// 次数卡余额
const gamePassRemaining = computed(() => {
return props.gamePasses?.total_remaining || 0
})
// 监听弹窗打开,若有次数卡则默认选中
watch(() => props.visible, (newVal) => {
if (newVal) {
// 若有次数卡,默认选中
useGamePass.value = gamePassRemaining.value > 0
}
})
function toggleGamePass() {
useGamePass.value = !useGamePass.value
// Mutually Exclusive: If Game Pass is ON, clear Coupon.
if (useGamePass.value) {
couponIndex.value = -1
}
}
const selectedCoupon = computed(() => {
if (couponIndex.value >= 0 && props.coupons[couponIndex.value]) {
return props.coupons[couponIndex.value]
}
return null
})
const maxDeductible = computed(() => {
const amt = Number(props.amount) || 0
return amt * 0.5
})
const effectiveCouponDiscount = computed(() => {
if (!selectedCoupon.value) return 0
const couponAmt = Number(selectedCoupon.value.amount) || 0
return Math.min(couponAmt, maxDeductible.value)
})
const displayCards = computed(() => {
if (!Array.isArray(props.propCards)) return []
return props.propCards.map(c => ({
...c,
displayName: (c.count && Number(c.count) > 1) ? `${c.name} (x${c.count})` : c.name
}))
})
const selectedCard = computed(() => {
if (cardIndex.value >= 0 && displayCards.value[cardIndex.value]) {
return displayCards.value[cardIndex.value]
}
return null
})
const finalPayAmount = computed(() => {
const amt = Number(props.amount) || 0
return Math.max(0, amt - effectiveCouponDiscount.value).toFixed(2)
})
watch(
[() => props.visible, () => (Array.isArray(props.coupons) ? props.coupons.length : 0)],
([vis, len]) => {
if (!vis) return
cardIndex.value = -1
if (len <= 0) {
couponIndex.value = -1
return
}
if (couponIndex.value < 0) {
couponIndex.value = 0
}
},
{ immediate: true }
)
function onCouponChange(e) {
couponIndex.value = e.detail.value
}
function onCardChange(e) {
cardIndex.value = e.detail.value
}
function openAgreement() {
uni.navigateTo({
url: '/pages-user/agreement/purchase'
})
}
function handleMaskClick() {
handleClose()
}
function handleClose() {
emit('update:visible', false)
emit('cancel')
}
function handleConfirm() {
emit('confirm', {
coupon: useGamePass.value ? null : selectedCoupon.value,
card: (props.showCards && !useGamePass.value) ? selectedCard.value : null,
useGamePass: useGamePass.value
})
}
</script>
<style lang="scss" scoped>
/* ============================================
祝福动画样式
============================================ */
.blessing-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
padding: 20rpx;
}
.blessing-animation {
text-align: center;
animation: blessingFadeIn 0.5s ease-out;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(255, 248, 243, 0.98));
padding: 40rpx 30rpx;
border-radius: 24rpx;
box-shadow: 0 12rpx 48rpx rgba(255, 107, 0, 0.3);
backdrop-filter: blur(20rpx);
border: 2rpx solid rgba(255, 159, 67, 0.3);
}
@keyframes blessingFadeIn {
from {
opacity: 0;
transform: translateY(-30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.blessing-emoji {
font-size: 100rpx;
line-height: 1;
margin-bottom: 16rpx;
display: block;
}
// 小羊动画 - 弹跳出现
.blessing-animation.sheep .blessing-emoji {
animation: emojiBounce 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
// 小马动画 - 从左边跑步进场
.blessing-animation.horse .blessing-emoji {
animation: emojiRun 1.5s ease-out;
}
// 橙子动画 - 缩放旋转出现(微信小程序优化版)
.blessing-animation.orange .blessing-emoji {
animation: emojiRotate 1.5s ease-out;
}
// 猴子动画 - 跳跃摇摆出现
.blessing-animation.monkey .blessing-emoji {
animation: emojiSwing 1.5s ease-out;
}
// 牛动画 - 冲撞弹跳出现
.blessing-animation.ox .blessing-emoji {
animation: emojiCharge 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
// 狗动画 - 摇尾巴跳动出现
.blessing-animation.dog .blessing-emoji {
animation: emojiWag 1.5s ease-in-out;
}
// 鸡动画 - 啄米点头出现
.blessing-animation.chicken .blessing-emoji {
animation: emojiPeck 1.5s ease-in-out;
}
@keyframes emojiBounce {
0% {
transform: scale(0) rotate(-180deg);
opacity: 0;
}
50% {
transform: scale(1.2) rotate(10deg);
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
@keyframes emojiRun {
0% {
transform: translateX(-300rpx) scale(0.8);
opacity: 0;
}
60% {
transform: translateX(30rpx) scale(1.1);
}
80% {
transform: translateX(-15rpx) scale(0.95);
}
100% {
transform: translateX(0) scale(1);
opacity: 1;
}
}
@keyframes emojiRotate {
0% {
transform: scale(0) rotate(0deg);
opacity: 0;
}
40% {
transform: scale(1.2) rotate(180deg);
opacity: 1;
}
60% {
transform: scale(0.95) rotate(360deg);
}
80% {
transform: scale(1.05) rotate(360deg);
}
100% {
transform: scale(1) rotate(360deg);
opacity: 1;
}
}
@keyframes emojiSwing {
0% {
transform: scale(0) translateY(-50rpx) rotate(-30deg);
opacity: 0;
}
40% {
transform: scale(1.15) translateY(10rpx) rotate(20deg);
opacity: 1;
}
60% {
transform: scale(0.9) translateY(-5rpx) rotate(-10deg);
}
80% {
transform: scale(1.05) translateY(3rpx) rotate(5deg);
}
100% {
transform: scale(1) translateY(0) rotate(0deg);
opacity: 1;
}
}
@keyframes emojiCharge {
0% {
transform: scale(0) translateX(-100rpx) rotate(45deg);
opacity: 0;
}
50% {
transform: scale(1.3) translateX(20rpx) rotate(-20deg);
opacity: 1;
}
70% {
transform: scale(0.85) translateX(-10rpx) rotate(10deg);
}
85% {
transform: scale(1.08) translateX(5rpx) rotate(-5deg);
}
100% {
transform: scale(1) translateX(0) rotate(0deg);
opacity: 1;
}
}
@keyframes emojiWag {
0% {
transform: scale(0) translateY(-30rpx) rotate(-15deg);
opacity: 0;
}
30% {
transform: scale(1.2) translateY(0) rotate(15deg);
opacity: 1;
}
50% {
transform: scale(0.9) translateY(-15rpx) rotate(-15deg);
}
70% {
transform: scale(1.1) translateY(0) rotate(15deg);
}
85% {
transform: scale(0.95) translateY(-5rpx) rotate(-5deg);
}
100% {
transform: scale(1) translateY(0) rotate(0deg);
opacity: 1;
}
}
@keyframes emojiPeck {
0% {
transform: scale(0) translateY(-40rpx) rotate(0deg);
opacity: 0;
}
25% {
transform: scale(1.15) translateY(10rpx) rotate(10deg);
opacity: 1;
}
40% {
transform: scale(0.85) translateY(-5rpx) rotate(-10deg);
}
55% {
transform: scale(1.1) translateY(8rpx) rotate(8deg);
}
70% {
transform: scale(0.9) translateY(-3rpx) rotate(-8deg);
}
85% {
transform: scale(1.05) translateY(2rpx) rotate(3deg);
}
100% {
transform: scale(1) translateY(0) rotate(0deg);
opacity: 1;
}
}
.blessing-subtitle {
font-size: 28rpx;
color: #FF9500;
font-weight: 700;
margin-top: 12rpx;
margin-bottom: 8rpx;
opacity: 0;
animation: subtitleFadeIn 0.5s ease-out 0.3s forwards;
}
@keyframes subtitleFadeIn {
from {
opacity: 0;
transform: translateY(10rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.blessing-text {
display: flex;
justify-content: center;
gap: 12rpx;
margin-top: 16rpx;
.char {
font-size: 48rpx;
font-weight: 900;
color: #FF6B00;
text-shadow: 0 4rpx 12rpx rgba(255, 107, 0, 0.3);
opacity: 0;
animation: charAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.char.from-left {
animation-name: charAppearFromLeft;
}
.char.from-right {
animation-name: charAppearFromRight;
}
}
@keyframes charAppear {
0% {
opacity: 0;
transform: translateY(30rpx) scale(0.5);
}
60% {
transform: translateY(-8rpx) scale(1.1);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes charAppearFromLeft {
0% {
opacity: 0;
transform: translateX(-80rpx) scale(0.5);
}
60% {
transform: translateX(10rpx) scale(1.1);
}
100% {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes charAppearFromRight {
0% {
opacity: 0;
transform: translateX(80rpx) scale(0.5);
}
60% {
transform: translateX(-10rpx) scale(1.1);
}
100% {
opacity: 1;
transform: translateX(0) scale(1);
}
}
/* ============================================
柯大鸭潮玩 - 支付弹窗组件
采用暖橙色调的底部弹窗设计
============================================ */
.payment-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.55);
z-index: 999;
display: flex;
align-items: flex-end;
}
.payment-popup-content {
width: 100%;
background: $bg-card;
border-radius: $radius-xl $radius-xl 0 0;
padding: $spacing-lg;
padding-bottom: calc($spacing-lg + constant(safe-area-inset-bottom));
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.risk-warning {
background: linear-gradient(135deg, #FFF8F3, #FFF4E6);
color: #B45309;
font-size: $font-xs;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
margin-bottom: $spacing-md;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
line-height: 1.5;
text-align: center;
border: 1rpx solid rgba(255, 159, 67, 0.2);
}
.agreement-link {
color: $brand-primary;
font-weight: 500;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-lg;
position: relative;
}
.popup-title {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
}
.close-icon {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
font-size: 48rpx;
color: $text-placeholder;
line-height: 1;
padding: 10rpx;
transition: color 0.2s ease;
}
.close-icon:active {
color: $text-secondary;
}
.popup-body {
padding: $spacing-sm 0 $spacing-md;
}
.amount-section {
text-align: center;
margin-bottom: $spacing-xl;
padding: $spacing-md;
background: linear-gradient(145deg, #FFFFFF, #FFF8F3);
border-radius: $radius-lg;
border: 1rpx solid rgba(255, 159, 67, 0.1);
}
.amount-section .label {
font-size: $font-sm;
color: $text-secondary;
margin-right: $spacing-xs;
}
.amount-section .amount {
font-size: 56rpx;
font-weight: 800;
background: $gradient-brand;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.form-item {
margin-bottom: $spacing-md;
}
.form-item .label {
display: block;
font-size: $font-sm;
color: $text-main;
font-weight: 600;
margin-bottom: $spacing-xs;
}
.picker-full {
width: 100%;
display: block;
}
.picker-display {
border: 2rpx solid $border-color-light;
border-radius: $radius-md;
padding: $spacing-md;
display: flex;
justify-content: space-between;
align-items: center;
font-size: $font-sm;
background: $bg-page;
transition: all 0.2s ease;
}
.picker-display:active {
border-color: $brand-primary;
background: #FFF8F3;
}
.selected-text {
color: $text-main;
font-weight: 500;
}
.placeholder {
color: $text-placeholder;
}
.arrow {
color: $text-placeholder;
width: 16rpx;
height: 16rpx;
border-right: 3rpx solid $text-placeholder;
border-bottom: 3rpx solid $text-placeholder;
transform: rotate(-45deg);
margin-right: $spacing-xs;
}
.popup-footer {
display: flex;
gap: $spacing-md;
margin-top: $spacing-sm;
}
.btn-cancel, .btn-confirm {
flex: 1;
border: none;
border-radius: $radius-lg;
font-size: $font-md;
padding: $spacing-md 0;
line-height: 1.5;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-cancel::after, .btn-confirm::after {
border: none;
}
.btn-cancel {
color: $text-secondary;
background: #F3F4F6;
}
.btn-cancel:active {
background: $border-color-light;
}
.btn-confirm {
color: #FFFFFF;
background: $gradient-brand;
box-shadow: $shadow-lg;
}
.btn-confirm:active {
transform: scale(0.97);
box-shadow: $shadow-md;
}
/* 次数卡使用按钮特殊样式 */
.btn-game-pass {
background: linear-gradient(135deg, #10B981, #059669);
}
/* ============================================
次数卡选项样式
============================================ */
.game-pass-section {
margin-bottom: $spacing-md;
}
.game-pass-option {
display: flex;
align-items: center;
padding: $spacing-md;
background: linear-gradient(135deg, #ECFDF5, #D1FAE5);
border: 2rpx solid #10B981;
border-radius: $radius-lg;
transition: all 0.2s ease;
&.active {
background: linear-gradient(135deg, #10B981, #059669);
border-color: #059669;
.game-pass-label, .game-pass-count {
color: #FFFFFF;
}
.game-pass-radio {
background: #FFFFFF;
border-color: #FFFFFF;
}
.radio-checked {
color: #10B981;
}
}
&.disabled {
background: #F9FAFB;
border-color: #E5E7EB;
.game-pass-radio {
border-color: #D1D5DB;
background: #F3F4F6;
}
}
}
.game-pass-radio {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 3rpx solid #10B981;
display: flex;
align-items: center;
justify-content: center;
margin-right: $spacing-sm;
}
.radio-checked {
font-size: 24rpx;
font-weight: bold;
color: #10B981;
}
.game-pass-info {
flex: 1;
display: flex;
flex-direction: column;
}
.game-pass-label {
font-size: $font-md;
font-weight: 600;
color: #059669;
}
.game-pass-count {
font-size: $font-sm;
color: #10B981;
margin-top: 4rpx;
}
.divider-line {
display: flex;
align-items: center;
margin-top: $spacing-md;
&::before, &::after {
content: '';
flex: 1;
height: 1rpx;
background: $border-color-light;
}
}
.divider-text {
font-size: $font-xs;
color: $text-placeholder;
padding: 0 $spacing-sm;
}
.radio-disabled {
width: 24rpx;
height: 24rpx;
background: #D1D5DB;
border-radius: 50%;
}
.text-disabled {
color: #9CA3AF !important;
}
</style>