689 lines
19 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>
<ActivityPageLayout :cover-url="coverUrl" bottom-padding="220rpx">
<template #header>
<ActivityHeader
:title="detail.name || detail.title || '无限赏'"
:price="detail.price_draw"
price-unit="/"
:cover-url="coverUrl"
:tags="['公开透明', '可验证']"
@show-rules="showRules"
@go-cabinet="goCabinet"
/>
</template>
<template #content>
<ActivityTabs v-model="tabActive" :stagger="1">
<template #pool>
<RewardsPreview
title="奖池配置"
:rewards="currentIssueRewards"
:grouped="true"
@view-all="rewardsVisible = true"
/>
</template>
<template #records>
<RecordsList :records="winRecords" />
</template>
</ActivityTabs>
</template>
<template #footer>
<!-- 底部多档位抽赏按钮 -->
<view class="bottom-actions">
<view class="tier-btn" @tap="openPayment(1)">
<text class="tier-price">¥{{ (pricePerDraw * 1).toFixed(2) }}</text>
<text class="tier-label">抽1发</text>
</view>
<view class="tier-btn" @tap="openPayment(3)">
<text class="tier-price">¥{{ (pricePerDraw * 3).toFixed(2) }}</text>
<text class="tier-label">抽3发</text>
</view>
<view class="tier-btn" @tap="openPayment(5)">
<text class="tier-price">¥{{ (pricePerDraw * 5).toFixed(2) }}</text>
<text class="tier-label">抽5发</text>
</view>
<view class="tier-btn tier-hot" @tap="openPayment(10)">
<text class="tier-price">¥{{ (pricePerDraw * 10).toFixed(2) }}</text>
<text class="tier-label">抽10发</text>
</view>
</view>
</template>
<template #modals>
<RewardsPopup
v-model:visible="rewardsVisible"
:title="`${currentIssueTitle} · 奖池与概率`"
:reward-groups="rewardGroups"
/>
<LotteryResultPopup
v-model:visible="showResultPopup"
:results="drawResults"
:show-retry-button="lastDrawUsedGamePass"
@close="onResultClose"
@retry="onRetryDraw"
/>
<PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="coupons"
:gamePasses="gamePasses"
:propCards="propCards"
@confirm="onPaymentConfirm"
/>
<RulesPopup
v-model:visible="rulesVisible"
:content="detail.gameplay_intro"
/>
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
<!-- 开奖加载弹窗 -->
<DrawLoadingPopup
:visible="showDrawLoading"
:progress="drawProgress"
:total="drawTotal"
/>
<GamePassPurchasePopup
v-model:visible="purchasePopupVisible"
:activity-id="activityId"
@success="onPurchaseSuccess"
/>
<!-- 悬浮次数卡入口 -->
<view v-if="gamePassRemaining > 0 || true" class="game-pass-float" @tap="openPurchasePopup">
<view class="badge-content">
<text class="badge-icon">🎮</text>
<text class="badge-text" v-if="gamePassRemaining > 0">{{ gamePassRemaining }}</text>
<text class="badge-text" v-else>购买</text>
</view>
<view class="badge-label">使用次数</view>
</view>
</template>
</ActivityPageLayout>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// 公共组件 - uni-app需要直接导入.vue文件
import ActivityPageLayout from '@/components/activity/ActivityPageLayout.vue'
import ActivityHeader from '@/components/activity/ActivityHeader.vue'
import ActivityTabs from '@/components/activity/ActivityTabs.vue'
import RewardsPreview from '@/components/activity/RewardsPreview.vue'
import RewardsPopup from '@/components/activity/RewardsPopup.vue'
import RecordsList from '@/components/activity/RecordsList.vue'
import RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import LotteryResultPopup from '@/components/activity/LotteryResultPopup.vue'
import DrawLoadingPopup from '@/components/activity/DrawLoadingPopup.vue'
import PaymentPopup from '@/components/PaymentPopup.vue'
import GamePassPurchasePopup from '@/components/GamePassPurchasePopup.vue'
import { getGamePasses } from '@/api/appUser'
// Composables
import { useActivity, useIssues, useRewards, useRecords } from '../../composables'
// API
import { joinLottery, createWechatOrder, getLotteryResult, getItemCards, getUserCoupons } from '@/api/appUser'
// ============ 使用Composables ============
const activityId = ref('')
const {
detail,
coverUrl,
fetchDetail,
setNavigationTitle
} = useActivity(activityId)
const pricePerDraw = computed(() => Number(detail.value?.price_draw || 0) / 100)
const {
issues,
currentIssueId,
currentIssueTitle,
fetchIssues
} = useIssues(activityId)
const {
currentIssueRewards,
rewardGroups,
fetchRewardsForIssues
} = useRewards(activityId, currentIssueId)
const {
winRecords,
fetchWinRecords
} = useRecords()
// ============ 本地状态 ============
const tabActive = ref('pool')
const rewardsVisible = ref(false)
const rulesVisible = ref(false)
const cabinetVisible = ref(false)
const showResultPopup = ref(false)
const drawResults = ref([])
const drawLoading = ref(false)
const showDrawLoading = ref(false)
const drawProgress = ref(0)
const drawTotal = ref(1)
const lastDrawUsedGamePass = ref(false) // 记录最后一次抽奖是否使用了次数卡
const lastDrawCount = ref(1) // 记录最后一次抽奖的数量
// 支付相关
const paymentVisible = ref(false)
const paymentAmount = ref('0.00')
const coupons = ref([])
const propCards = ref([])
const pendingCount = ref(1)
const selectedCoupon = ref(null)
const selectedCard = ref(null)
const useGamePassFlag = ref(false)
// ============ 次数卡逻辑 ============
const gamePasses = ref(null)
const gamePassRemaining = computed(() => gamePasses.value?.total_remaining || 0)
const purchasePopupVisible = ref(false)
async function fetchPasses() {
if (!activityId.value) return
try {
const res = await getGamePasses(activityId.value)
gamePasses.value = res || null
} catch (e) {
gamePasses.value = null
}
}
function openPurchasePopup() {
purchasePopupVisible.value = true
}
function onPurchaseSuccess() {
fetchPasses()
}
// ============ 业务方法 ============
function showRules() {
rulesVisible.value = true
}
function goCabinet() {
cabinetVisible.value = true
}
function onResultClose() {
showResultPopup.value = false
drawResults.value = []
}
function onRetryDraw() {
// 关闭结果弹窗
showResultPopup.value = false
drawResults.value = []
// 检查是否还有剩余次数卡
if (gamePassRemaining.value > 0) {
// 使用次数卡直接抽奖
useGamePassFlag.value = true
selectedCoupon.value = null
selectedCard.value = null
onMachineDraw(lastDrawCount.value)
} else {
// 次数卡已用完,打开支付弹窗
openPayment(lastDrawCount.value)
}
}
function openPayment(count) {
const times = Math.max(1, Number(count || 1))
pendingCount.value = times
paymentAmount.value = (pricePerDraw.value * times).toFixed(2)
const token = uni.getStorageSync('token')
// 使用统一的手机号绑定检查
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
if (!token || !hasPhoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
paymentVisible.value = true
// 并行获取道具卡和优惠券
Promise.all([fetchPropCards(), fetchCoupons()])
}
async function onPaymentConfirm(data) {
selectedCoupon.value = data?.coupon || null
selectedCard.value = data?.card || null
useGamePassFlag.value = data?.useGamePass || false
paymentVisible.value = false
await onMachineDraw(pendingCount.value)
}
async function fetchPropCards() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getItemCards(user_id)
let list = Array.isArray(res) ? res : (res?.list || res?.data || [])
// Group identical cards by name
const groupedMap = new Map()
list.forEach((i, idx) => {
const name = i.name ?? i.title ?? '道具卡'
if (!groupedMap.has(name)) {
groupedMap.set(name, {
id: i.id ?? i.card_id ?? String(idx),
name: name,
count: 0
})
}
groupedMap.get(name).count++
})
propCards.value = Array.from(groupedMap.values())
} catch (e) {
propCards.value = []
}
}
async function fetchCoupons() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getUserCoupons(user_id, 0, 1, 100)
let list = Array.isArray(res) ? res : (res?.list || res?.data || [])
coupons.value = list.map((i, idx) => {
const amountCents = i.remaining ?? i.amount ?? i.value ?? 0
const amt = isNaN(amountCents) ? 0 : (Number(amountCents) / 100)
return {
id: i.id ?? i.coupon_id ?? String(idx),
name: i.name ?? i.title ?? '优惠券',
amount: amt.toFixed(2)
}
})
} catch (e) {
coupons.value = []
}
}
function extractResultList(resultRes) {
const root = resultRes?.data ?? resultRes?.result ?? resultRes
if (!root) return []
// Backend now returns results array with all draw logs including doubled
if (resultRes?.results && Array.isArray(resultRes.results) && resultRes.results.length > 0) {
return resultRes.results
}
return root.results || root.list || root.items || root.data || []
}
function mapResultsToFlipItems(resultRes, poolRewards) {
const list = extractResultList(resultRes)
const poolArr = Array.isArray(poolRewards) ? poolRewards : []
const lookup = new Map()
poolArr.forEach(it => {
const id = it?.id ?? it?.reward_id ?? it?.product_id
if (id !== undefined) lookup.set(Number(id), it)
})
return list.filter(Boolean).map(d => {
const rewardId = d.reward_id ?? d.rewardId ?? d.product_id ?? d.productId ?? d.id
const rewardName = String(d.reward_name ?? d.rewardName ?? d.product_name ?? d.productName ?? d.title ?? d.name ?? '')
const fromId = Number.isFinite(Number(rewardId)) ? lookup.get(Number(rewardId)) : null
const fromName = !fromId && rewardName ? poolArr.find(x => x?.title === rewardName) : null
const it = fromId || fromName || null
return {
reward_id: rewardId, // 添加reward_id用于正确分组
title: rewardName || it?.title || '奖励',
image: d.image || it?.image || d.img || d.pic || d.product_image || ''
}
})
}
async function onMachineDraw(count) {
const aid = activityId.value
const iid = currentIssueId.value
if (!aid || !iid) {
uni.showToast({ title: '期数未选择', icon: 'none' })
return
}
const token = uni.getStorageSync('token')
// 使用统一的手机号绑定检查
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
if (!token || !hasPhoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
const openid = uni.getStorageSync('openid')
if (!openid) {
uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' })
return
}
drawLoading.value = true
try {
const times = Math.max(1, Number(count || 1))
const joinRes = await joinLottery({
activity_id: Number(aid),
issue_id: Number(iid),
channel: 'miniapp',
count: times,
coupon_id: selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0,
item_card_id: selectedCard.value?.id ? Number(selectedCard.value.id) : 0,
use_game_pass: useGamePassFlag.value
})
// 支付成功刷新次数卡
if (useGamePassFlag.value) {
fetchPasses()
}
const orderNo = joinRes?.order_no || joinRes?.data?.order_no || joinRes?.result?.order_no
if (!orderNo) throw new Error('未获取到订单号')
// Check if order is already paid (e.g. via Game Pass or Points)
const isPaid = (joinRes?.status === 2) || (joinRes?.actual_amount <= 0)
if (!isPaid) {
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 || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
}
// 支付成功后立即显示开奖加载弹窗
drawTotal.value = times
drawProgress.value = 0
showDrawLoading.value = true
// 轮询等待开奖完成
let resultRes = await getLotteryResult(orderNo)
let pollCount = 0
const maxPolls = 15 // 最多轮询15次每次2秒共30秒
while (resultRes?.status === 'paid_waiting' &&
resultRes?.completed < resultRes?.count &&
pollCount < maxPolls) {
// 更新进度
drawProgress.value = resultRes?.completed || 0
await new Promise(r => setTimeout(r, resultRes?.nextPollMs || 2000))
resultRes = await getLotteryResult(orderNo)
pollCount++
}
// 隐藏加载弹窗
showDrawLoading.value = false
const items = mapResultsToFlipItems(resultRes, currentIssueRewards.value)
drawResults.value = items
// 记录最后一次抽奖是否使用了次数卡,用于"再来一次"按钮
lastDrawUsedGamePass.value = useGamePassFlag.value
lastDrawCount.value = times
showResultPopup.value = true
} catch (e) {
showDrawLoading.value = false
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
drawLoading.value = false
}
}
// ============ 生命周期 ============
onLoad(async (opts) => {
const id = opts?.id || ''
if (!id) return
activityId.value = id
// 并行获取活动详情和期数信息
await Promise.all([fetchDetail(), fetchIssues()])
setNavigationTitle('无限赏')
// 期数获取完成后获取奖励
await fetchRewardsForIssues(issues.value)
// 异步获取记录(不阻塞渲染)
if (currentIssueId.value) {
fetchWinRecords(id, currentIssueId.value)
}
fetchPasses()
})
// 监听期切换,刷新记录
watch(currentIssueId, (newId) => {
if (newId && activityId.value) {
fetchWinRecords(activityId.value, newId)
}
})
</script>
<style lang="scss" scoped>
/* 底部多档位操作按钮 - 原始设计 */
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 20rpx;
padding: 32rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(30rpx);
box-shadow: 0 -12rpx 40rpx rgba(0, 0, 0, 0.08);
z-index: 999;
border-top: 1rpx solid rgba(255, 255, 255, 0.8);
}
.tier-btn {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx 10rpx;
background: #FFF;
border: 2rpx solid rgba($brand-primary, 0.1);
border-radius: 28rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.03);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
margin: 0;
line-height: normal;
&::after {
border: none;
}
&:active {
transform: scale(0.92);
background: #F9F9F9;
box-shadow: none;
}
}
.tier-price {
font-size: 34rpx;
font-weight: 900;
color: $text-main;
font-family: 'DIN Alternate', sans-serif;
letter-spacing: -1rpx;
}
.tier-label {
font-size: 22rpx;
color: $brand-primary;
margin-top: 6rpx;
font-weight: 800;
font-style: italic;
}
/* 热门/最高档位 - 高级动效 */
.tier-hot {
background: $gradient-brand !important;
border: none !important;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.35) !important;
position: relative;
overflow: hidden;
.tier-price {
color: #FFF !important;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.tier-label {
color: rgba(255, 255, 255, 0.9) !important;
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.1);
}
/* 流光效果 */
&::before {
content: '';
position: absolute;
top: -50%;
left: -150%;
width: 200%;
height: 200%;
background: linear-gradient(
to right,
transparent,
rgba(255, 255, 255, 0.25),
transparent
);
transform: rotate(30deg);
animation: shine 3s ease-in-out infinite;
}
&:active {
transform: scale(0.92);
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.25) !important;
}
}
@keyframes shine {
0% { left: -150%; }
50%, 100% { left: 150%; }
}
/* 翻牌弹窗 */
.flip-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
}
.flip-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10rpx);
}
.flip-content {
position: relative;
width: 90%;
max-height: 85vh;
background: rgba($bg-card, 0.95);
border-radius: $radius-xl;
padding: $spacing-lg;
overflow: hidden;
box-shadow: $shadow-card;
}
.overlay-close {
margin-top: $spacing-lg;
width: 100%;
background: $gradient-brand;
color: #fff;
border: none;
border-radius: $radius-lg;
font-size: $font-md;
font-weight: 600;
padding: $spacing-md;
&::after {
border: none;
}
}
/* 次数卡悬浮入口 */
.game-pass-float {
position: fixed;
right: 32rpx;
bottom: calc(180rpx + env(safe-area-inset-bottom));
z-index: 990;
display: flex;
flex-direction: column;
align-items: center;
animation: float 3s ease-in-out infinite;
}
.badge-content {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10rpx);
border-radius: 30rpx;
padding: 8rpx 16rpx;
display: flex;
align-items: center;
box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.15);
border: 1rpx solid rgba($brand-primary, 0.2);
}
.badge-icon { font-size: 28rpx; margin-right: 6rpx; }
.badge-text { font-size: 24rpx; font-weight: 800; color: $brand-primary; }
.badge-label {
font-size: 20rpx;
color: #fff;
background: $gradient-brand;
padding: 2rpx 8rpx;
border-radius: 8rpx;
margin-top: -6rpx;
z-index: 2;
box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.2);
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10rpx); }
}
</style>