709 lines
18 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">
<template #header>
<ActivityHeader
:title="detail.name || detail.title || '一番赏活动'"
:price="detail.price_draw"
price-unit="/"
:cover-url="coverUrl"
:tags="['公开透明', '拒绝套路']"
:scheduled-time="scheduledTimeText"
@show-rules="showRules"
@go-cabinet="goCabinet"
/>
</template>
<template #content>
<!-- 赏品概览 -->
<ActivityTabs v-model="tabActive" :stagger="1">
<template #pool>
<RewardsPreview
title="奖品配置"
:rewards="currentIssueRewards"
:grouped="false"
@view-all="rewardsVisible = true"
/>
</template>
<template #records>
<RecordsList :records="winRecords" />
</template>
</ActivityTabs>
<!-- 选号区域一番赏专属 -->
<view class="section-container selector-container animate-enter stagger-2">
<!-- 期号切换 -->
<view class="issue-header">
<view class="issue-switch-btn" @click="prevIssue">
<text class="arrow"></text>
</view>
<view class="issue-info-center">
<text class="issue-current-text">{{ currentIssueTitle }}</text>
<text class="issue-status-badge">进行中</text>
</view>
<view class="issue-switch-btn" @click="nextIssue">
<text class="arrow"></text>
</view>
</view>
<view class="issue-block-tip" v-if="!isOrderAllowed">
<text class="issue-block-text">{{ orderBlockedReason }}</text>
</view>
<!-- 选号组件 - 隐藏内置操作栏 -->
<view class="selector-body" v-if="activityId && currentIssueId">
<YifanSelector
ref="yifanSelectorRef"
:activity-id="activityId"
:issue-id="currentIssueId"
:price-per-draw="Number(detail.price_draw || 0) / 100"
:disabled="!isOrderAllowed"
:disabled-text="orderBlockedReason"
:hide-action-bar="true"
@payment-success="onPaymentSuccess"
@selection-change="onSelectionChange"
@payment-visible-change="onPaymentVisibleChange"
@payment-amount-change="onPaymentAmountChange"
@payment-coupons-change="onPaymentCouponsChange"
/>
</view>
</view>
</template>
<template #footer>
<!-- 固定底部操作栏 -->
<view class="float-bar" v-show="!isPaymentVisible">
<view class="float-bar-inner">
<view class="selection-info" v-if="selectedCount > 0">
已选 <text class="highlight">{{ selectedCount }}</text> 个位置
</view>
<view class="selection-info" v-if="selectedCount > 0">
已选 <text class="highlight">{{ selectedCount }}</text> 个位置
</view>
<view class="selection-info" v-else>
<!-- 次数卡余额 / 购买入口 -->
<view v-if="gamePassRemaining > 0" class="game-pass-badge" @tap="() => {}">
<text class="badge-icon">🎮</text>
<text class="badge-text" style="font-size: 24rpx; font-weight: bold; color: #10B981;">{{ gamePassRemaining }}</text>
</view>
<!-- 充值入口 -->
<view class="game-pass-buy-btn" @tap="openPurchasePopup">
<text>充值特惠</text>
</view>
请选择位置
</view>
<view class="action-buttons">
<button v-if="selectedCount === 0" class="action-btn primary" @tap="handleRandomDraw" :disabled="!isOrderAllowed">随机一发</button>
<button v-else class="action-btn primary" @tap="handlePayment" :disabled="!isOrderAllowed">去支付</button>
</view>
</view>
</view>
</template>
<template #modals>
<!-- 翻牌弹窗 -->
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
<view class="flip-mask" @tap="closeFlip"></view>
<view class="flip-content" @tap.stop>
<FlipGrid ref="flipRef" :rewards="currentIssueRewards" :controls="false" />
<button class="overlay-close" @tap="closeFlip">关闭</button>
</view>
</view>
<!-- 奖品弹窗 -->
<RewardsPopup
v-model:visible="rewardsVisible"
:title="`${currentIssueTitle} · 奖品与概率`"
:reward-groups="rewardGroups"
/>
<!-- 规则弹窗 -->
<RulesPopup
v-model:visible="rulesVisible"
:content="detail.gameplay_intro"
/>
<!-- 盒柜预览弹窗 -->
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
<!-- 支付弹窗 YifanSelector 提升到这里确保祝福动画位置正确 -->
<PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="paymentCoupons"
:gamePasses="gamePasses"
:showCards="false"
@confirm="onPaymentConfirm"
@cancel="onPaymentCancel"
/>
<GamePassPurchasePopup
v-model:visible="purchasePopupVisible"
:activity-id="activityId"
@success="onPurchaseSuccess"
/>
</template>
</ActivityPageLayout>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { onLoad, onUnload } 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 FlipGrid from '@/components/FlipGrid.vue'
import YifanSelector from '@/components/YifanSelector.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'
// Utils
import { formatDateTime, parseTimeMs } from '@/utils/format'
// ============ 使用Composables ============
const activityId = ref('')
const {
detail,
coverUrl,
fetchDetail,
setNavigationTitle
} = useActivity(activityId)
const {
issues,
currentIssueId,
currentIssueTitle,
fetchIssues,
prevIssue,
nextIssue
} = 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 showFlip = ref(false)
const flipRef = ref(null)
const yifanSelectorRef = ref(null)
const selectedCount = ref(0) // 从外部追踪选中数量
const isPaymentVisible = ref(false) // 支付弹窗是否显示
const paymentVisible = ref(false) // 控制支付弹窗显示
const paymentAmount = ref('0') // 支付金额
const paymentCoupons = ref([]) // 可用优惠券
// 接收选中变化事件
function onSelectionChange(items) {
selectedCount.value = Array.isArray(items) ? items.length : 0
}
// 接收支付弹窗显示状态变化(从 YifanSelector
function onPaymentVisibleChange(visible) {
isPaymentVisible.value = visible
paymentVisible.value = visible
}
// 接收支付金额变化
function onPaymentAmountChange(amount) {
paymentAmount.value = amount
}
// 接收优惠券变化
function onPaymentCouponsChange(coupons) {
paymentCoupons.value = coupons
}
// 支付确认处理(委托给 YifanSelector
async function onPaymentConfirm(paymentData) {
if (yifanSelectorRef.value && yifanSelectorRef.value.onPaymentConfirm) {
await yifanSelectorRef.value.onPaymentConfirm(paymentData)
}
}
// 支付取消处理
function onPaymentCancel() {
// PaymentPopup 会通过 v-model 自动更新 paymentVisible
// watch 会监听到变化并同步给 YifanSelector
}
// 监听支付弹窗状态变化,同步给 YifanSelector
watch(paymentVisible, (newVal) => {
// 当支付弹窗关闭时,通知 YifanSelector 更新内部状态
if (!newVal && yifanSelectorRef.value && yifanSelectorRef.value.setPaymentVisible) {
yifanSelectorRef.value.setPaymentVisible(false)
}
})
// 触发随机选号
function handleRandomDraw() {
if (yifanSelectorRef.value && yifanSelectorRef.value.handleRandomOne) {
yifanSelectorRef.value.handleRandomOne()
}
}
// 触发支付
function handlePayment() {
if (yifanSelectorRef.value && yifanSelectorRef.value.handleBuy) {
yifanSelectorRef.value.handleBuy()
}
}
// ============ 次数卡逻辑 ============
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()
}
// ============ 倒计时相关(一番赏专属) ============
const nowMs = ref(Date.now())
let nowTimer = null
function startNowTicker() {
stopNowTicker()
nowMs.value = Date.now()
nowTimer = setInterval(() => { nowMs.value = Date.now() }, 1000)
}
function stopNowTicker() {
if (nowTimer) {
clearInterval(nowTimer)
nowTimer = null
}
}
const scheduledTime = computed(() => detail.value?.scheduled_time || detail.value?.scheduledTime || '')
const scheduledTimeMs = computed(() => parseTimeMs(scheduledTime.value))
const scheduledTimeText = computed(() => formatDateTime(scheduledTime.value))
const remainMs = computed(() => {
const end = scheduledTimeMs.value
if (!end) return null
return end - nowMs.value
})
const isOrderAllowed = computed(() => {
const ms = remainMs.value
if (ms === null) return true
return ms > 25000
})
const orderBlockedReason = computed(() => {
const ms = remainMs.value
if (ms === null) return ''
if (ms <= 0) return '本期已结束,暂不可下单'
if (ms <= 25000) return '距本期结束不足25秒暂不可下单'
return ''
})
// ============ 业务方法 ============
function showRules() {
rulesVisible.value = true
}
function goCabinet() {
cabinetVisible.value = true
}
function closeFlip() {
showFlip.value = false
}
function onPaymentSuccess(payload) {
console.log('Payment Success:', payload)
const result = payload.result
const status = String(result?.status || result?.data?.status || result?.result?.status || '')
if (status === 'paid_waiting') {
const next = result?.next_draw_time || result?.nextDrawTime || result?.next_draw_at || result?.nextDrawAt
const nextText = next ? formatDateTime(next) : ''
const content = nextText
? `下单成功,等待系统自动开启本期赏品。\n预计开赏时间${nextText}`
: '下单成功,等待系统自动开启本期赏品。'
uni.showModal({
title: '下单成功',
content,
showCancel: false
})
return
}
let wonItems = []
if (Array.isArray(result)) {
wonItems = result
} else if (result?.list) {
wonItems = result.list
} else if (result?.data) {
wonItems = result.data
} else if (result?.rewards) {
wonItems = result.rewards
} else {
wonItems = result ? [result] : []
}
const items = wonItems.map(data => ({
title: String(data?.title || data?.name || data?.product_name || data?.reward_name || '未知奖励'),
image: String(data?.image || data?.img || data?.pic || data?.product_image || data?.reward_image || '')
}))
showFlip.value = true
try { flipRef.value?.reset?.() } catch (_) {}
setTimeout(() => {
flipRef.value?.revealResults?.(items)
}, 100)
}
// ============ 生命周期 ============
onLoad(async (opts) => {
startNowTicker()
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()
})
onUnload(() => {
stopNowTicker()
})
// 监听期切换,刷新记录
watch(currentIssueId, (newId) => {
if (newId && activityId.value) {
fetchWinRecords(activityId.value, newId)
}
})
</script>
<style lang="scss" scoped>
/* 选号容器 - 与原始设计一致 */
.section-container {
margin: 0 $spacing-lg $spacing-lg;
background: rgba(255, 255, 255, 0.9);
border-radius: $radius-xl;
padding: $spacing-lg;
box-shadow: $shadow-sm;
backdrop-filter: blur(10rpx);
}
.selector-container {
margin-top: $spacing-md;
}
/* 期号切换 - 与原始设计一致 */
.issue-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
background: $bg-grey;
border-radius: $radius-round;
padding: 10rpx;
border: 1rpx solid $border-color-light;
}
.issue-switch-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background: $bg-card;
border-radius: 50%;
box-shadow: $shadow-sm;
transition: all 0.2s;
color: $text-secondary;
&:active {
transform: scale(0.9);
background: $bg-secondary;
color: $brand-primary;
}
}
.arrow {
font-size: $font-sm;
font-weight: 800;
}
.issue-info-center {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.issue-current-text {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
}
.issue-status-badge {
font-size: $font-xs;
color: $uni-color-success;
background: rgba($uni-color-success, 0.1);
padding: 2rpx $spacing-md;
border-radius: $radius-round;
margin-top: 4rpx;
font-weight: 600;
}
.issue-block-tip {
background: rgba($color-warning, 0.1);
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
margin-bottom: $spacing-md;
}
.issue-block-text {
font-size: $font-sm;
color: $color-warning;
font-weight: 500;
}
.selector-body {
margin-top: $spacing-sm;
}
/* 入场动画 */
.animate-enter {
animation: slideUp 0.5s ease-out both;
}
.stagger-2 {
animation-delay: 0.2s;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 翻牌弹窗 */
.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);
}
.flip-content {
position: relative;
width: 90%;
max-height: 85vh;
background: $bg-card;
border-radius: $radius-xl;
padding: $spacing-lg;
overflow: hidden;
}
.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;
}
}
/* ============= 底部固定操作栏 ============= */
.float-bar {
position: fixed;
left: 32rpx;
right: 32rpx;
bottom: calc(40rpx + env(safe-area-inset-bottom));
z-index: 100;
animation: slideUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) backwards;
}
.float-bar-inner {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(30rpx);
padding: 24rpx 40rpx;
border-radius: 999rpx;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.selection-info {
font-size: 28rpx;
color: $text-main;
display: flex;
align-items: baseline;
font-weight: 800;
}
.highlight {
color: $brand-primary;
font-weight: 900;
font-size: 40rpx;
margin: 0 8rpx;
font-family: 'DIN Alternate', sans-serif;
}
.action-buttons {
display: flex;
gap: 20rpx;
}
.action-btn {
height: 88rpx;
line-height: 88rpx;
padding: 0 56rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 900;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
border: none;
&::after {
border: none;
}
&:active {
transform: scale(0.92);
}
&.primary {
background: $gradient-brand !important;
color: #FFFFFF !important;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.35);
}
}
</style>
<style lang="scss" scoped>
/* 浮动操作栏扩展 - 充值按钮 & Badge */
.game-pass-badge {
display: flex;
align-items: center;
background: rgba(16, 185, 129, 0.15);
padding: 6rpx 16rpx;
border-radius: 30rpx;
border: 1rpx solid rgba(16, 185, 129, 0.3);
margin: 0 12rpx;
animation: pulse 2s infinite;
.badge-icon {
font-size: 28rpx;
margin-right: 6rpx;
}
.badge-text {
font-size: 24rpx;
color: #10B981;
font-weight: 600;
}
&:active {
opacity: 0.8;
}
}
.game-pass-buy-btn {
background: linear-gradient(90deg, #FF9F43, #FF6B00);
color: #fff;
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 24rpx;
margin-right: 12rpx;
font-weight: 600;
box-shadow: 0 4rpx 8rpx rgba(255, 107, 0, 0.2);
&:active {
transform: scale(0.95);
}
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
</style>