feat(activity): 新增小程序奖品领取弹窗

新增首页承载的奖品领取弹窗与领取接口接入,支持待领取检查、会话静默关闭与领取操作展示。
This commit is contained in:
Zuncle 2026-05-07 22:10:15 +08:00
parent 8a3676eb9f
commit d530ec11e7
5 changed files with 367 additions and 5 deletions

View File

@ -11,7 +11,6 @@ export default {
try { uni.setStorageSync('inviter_code', options.query.invite_code) } catch (e) { console.error('Save invite code failed', e) }
}
// (ID)
getPublicConfig().then(res => {
if (res && res.subscribe_templates) {
console.log('Loaded public config:', res)
@ -20,8 +19,6 @@ export default {
}).catch(err => {
console.warn('Failed to load public config:', err)
})
//
},
onShow: function() {
console.log('App Show')

9
api/prizeClaim.js Normal file
View File

@ -0,0 +1,9 @@
import { authRequest } from '@/utils/request'
export function getPendingPrizeGrantActivity() {
return authRequest({ url: '/api/app/prize-grant-activities/pending', method: 'GET' })
}
export function claimPrizeGrantActivity(id) {
return authRequest({ url: `/api/app/prize-grant-activities/${id}/claim`, method: 'POST' })
}

View File

@ -0,0 +1,289 @@
<template>
<view v-if="visible && activity" class="prize-claim-overlay" @touchmove.stop.prevent>
<view class="prize-claim-mask" @tap="handleClose"></view>
<view class="prize-claim-panel" @tap.stop>
<view class="prize-claim-hero">
<view class="hero-glow"></view>
<view class="hero-top">
<view class="hero-badge">奖励发放</view>
<text class="prize-claim-close" @tap="handleClose">×</text>
</view>
<view class="hero-content">
<text class="hero-title">奖励已到账待你领取</text>
<text class="hero-reason">{{ activity.reason }}</text>
</view>
</view>
<view class="prize-claim-body">
<view class="section-title">奖品内容</view>
<view class="reward-list">
<view
v-for="(item, index) in activity.rewards || []"
:key="`${item.reward_type}-${item.reward_ref_id}-${index}`"
class="reward-item"
>
<view class="reward-thumb-wrap">
<image v-if="item.image" class="reward-thumb" :src="item.image" mode="aspectFill" />
<view v-else class="reward-thumb-empty">{{ typeShortLabel(item.reward_type) }}</view>
</view>
<view class="reward-main">
<text class="reward-name">{{ item.name || item.reward_type }}</text>
<view class="reward-meta-row">
<text class="reward-type">{{ typeLabel(item.reward_type) }}</text>
<text v-if="item.value_cents > 0" class="reward-value">单价 ¥{{ (item.value_cents / 100).toFixed(2) }}</text>
</view>
</view>
<view class="reward-side">
<text class="reward-quantity">x{{ item.quantity }}</text>
</view>
</view>
</view>
</view>
<view class="prize-claim-footer">
<button class="claim-button" :disabled="loading" @tap="handleClaim">
{{ loading ? '领取中...' : '立即领取' }}
</button>
</view>
</view>
</view>
</template>
<script setup>
const props = defineProps({
visible: { type: Boolean, default: false },
activity: { type: Object, default: null },
loading: { type: Boolean, default: false }
})
const emit = defineEmits(['update:visible', 'claim', 'close'])
function handleClose() {
emit('close')
emit('update:visible', false)
}
function handleClaim() {
if (!props.loading) emit('claim')
}
function typeLabel(type) {
if (type === 'product') return '商品'
if (type === 'coupon') return '优惠券'
if (type === 'item_card') return '道具卡'
return type
}
function typeShortLabel(type) {
if (type === 'product') return '商品'
if (type === 'coupon') return '券'
if (type === 'item_card') return '卡'
return '奖'
}
</script>
<style lang="scss" scoped>
.prize-claim-overlay {
position: fixed;
inset: 0;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
}
.prize-claim-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6rpx);
}
.prize-claim-panel {
position: relative;
width: 88%;
max-height: 78vh;
background: $bg-card;
border-radius: $radius-xl;
overflow: hidden;
box-shadow: $shadow-lg;
animation: slideUp 0.25s ease-out;
}
.prize-claim-hero {
position: relative;
padding: $spacing-lg $spacing-lg $spacing-xl;
background: linear-gradient(135deg, $brand-primary 0%, $brand-primary-light 100%);
}
.hero-glow {
position: absolute;
right: -80rpx;
top: -80rpx;
width: 260rpx;
height: 260rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
}
.hero-top {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $spacing-lg;
}
.hero-badge {
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
color: #fff;
font-size: $font-xs;
font-weight: 700;
}
.prize-claim-close {
color: rgba(255, 255, 255, 0.9);
font-size: 48rpx;
line-height: 1;
}
.hero-content {
position: relative;
}
.hero-title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: #fff;
line-height: 1.3;
margin-bottom: 12rpx;
}
.hero-reason {
display: block;
font-size: $font-md;
color: rgba(255, 255, 255, 0.92);
line-height: 1.6;
}
.prize-claim-body {
padding: $spacing-lg;
}
.section-title {
font-size: $font-md;
font-weight: 700;
color: $text-main;
margin-bottom: $spacing-md;
}
.reward-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.reward-item {
display: flex;
align-items: center;
gap: $spacing-md;
background: $bg-page;
border-radius: $radius-lg;
padding: $spacing-md;
}
.reward-thumb-wrap {
width: 112rpx;
height: 112rpx;
border-radius: $radius-md;
overflow: hidden;
flex-shrink: 0;
background: #fff;
box-shadow: $shadow-sm;
}
.reward-thumb {
width: 100%;
height: 100%;
}
.reward-thumb-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-sm;
color: $text-sub;
background: rgba($brand-primary, 0.08);
}
.reward-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
min-width: 0;
}
.reward-name {
font-size: $font-md;
color: $text-main;
font-weight: 700;
line-height: 1.4;
}
.reward-meta-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.reward-type,
.reward-value,
.reward-quantity {
font-size: $font-sm;
color: $text-sub;
}
.reward-value {
color: $brand-primary-dark;
font-weight: 600;
}
.reward-side {
display: flex;
align-items: center;
justify-content: center;
min-width: 64rpx;
}
.reward-quantity {
font-size: $font-md;
font-weight: 700;
color: $brand-primary;
}
.prize-claim-footer {
padding: 0 $spacing-lg $spacing-lg;
}
.claim-button {
width: 100%;
border: none;
border-radius: $radius-round;
background: linear-gradient(135deg, $brand-primary, $brand-primary-light);
color: #fff;
font-size: $font-md;
font-weight: 700;
padding: 24rpx 0;
box-shadow: $shadow-warm;
}
.claim-button[disabled] {
opacity: 0.65;
}
</style>

View File

@ -8,3 +8,4 @@ export { default as ActivityTabs } from './ActivityTabs.vue'
export { default as RewardsPreview } from './RewardsPreview.vue'
export { default as RewardsPopup } from './RewardsPopup.vue'
export { default as RecordsList } from './RecordsList.vue'
export { default as PrizeClaimPopup } from './PrizeClaimPopup.vue'

View File

@ -140,19 +140,30 @@
<!-- 底部垫高 - 避开TabBar -->
<view style="height: 140rpx"></view>
</scroll-view>
<PrizeClaimPopup
v-model:visible="prizeClaimVisible"
:activity="prizeClaimActivity"
:loading="prizeClaimLoading"
@close="handlePrizeClaimClose"
@claim="handlePrizeClaim"
/>
</view>
</template>
<script>
import { authRequest, request } from '../../utils/request.js'
import { getPendingPrizeGrantActivity, claimPrizeGrantActivity } from '@/api/prizeClaim'
import SplashScreen from '@/components/SplashScreen.vue'
import PrizeClaimPopup from '@/components/activity/PrizeClaimPopup.vue'
// #ifdef MP-TOUTIAO
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
// #endif
export default {
components: {
SplashScreen
SplashScreen,
PrizeClaimPopup
// #ifdef MP-TOUTIAO
, customTabBarToutiao
// #endif
@ -166,7 +177,12 @@ export default {
bannerIndex: 0,
isHomeLoading: false,
swiperAutoplay: true,
swiperKey: 0
swiperKey: 0,
prizeClaimVisible: false,
prizeClaimLoading: false,
prizeClaimActivity: null,
prizeClaimChecking: false,
prizeClaimLastCheckAt: 0
}
},
computed: {
@ -224,6 +240,7 @@ export default {
if (this.activities.length === 0 && !this.isHomeLoading) {
this.loadHomeData()
}
this.checkPrizeGrantActivity()
},
onHide() {
this.swiperAutoplay = false
@ -262,6 +279,55 @@ export default {
const fn = token ? authRequest : request
return fn({ url })
},
getPrizeClaimSessionKey() {
const userId = uni.getStorageSync('user_id')
const sessionId = uni.getStorageSync('app_session_id')
if (!userId || !sessionId) return ''
return `prize_claim_closed:${userId}:${sessionId}`
},
async checkPrizeGrantActivity(force = false) {
const token = uni.getStorageSync('token')
if (!token || this.prizeClaimChecking || this.prizeClaimVisible) return
const sessionKey = this.getPrizeClaimSessionKey()
if (!force && sessionKey && uni.getStorageSync(sessionKey)) return
const now = Date.now()
if (!force && now - this.prizeClaimLastCheckAt < 10000) return
this.prizeClaimChecking = true
this.prizeClaimLastCheckAt = now
try {
const res = await getPendingPrizeGrantActivity()
if (res && res.has_pending && res.activity) {
this.prizeClaimActivity = res.activity
this.prizeClaimVisible = true
}
} catch (err) {
console.warn('checkPrizeGrantActivity failed', err)
} finally {
this.prizeClaimChecking = false
}
},
handlePrizeClaimClose() {
const sessionKey = this.getPrizeClaimSessionKey()
if (sessionKey) {
try { uni.setStorageSync(sessionKey, 1) } catch (_) {}
}
this.prizeClaimVisible = false
},
async handlePrizeClaim() {
if (!this.prizeClaimActivity?.id || this.prizeClaimLoading) return
this.prizeClaimLoading = true
try {
await claimPrizeGrantActivity(this.prizeClaimActivity.id)
uni.showToast({ title: '领取成功', icon: 'success' })
this.prizeClaimVisible = false
this.prizeClaimActivity = null
this.checkPrizeGrantActivity(true)
} catch (err) {
uni.showToast({ title: err?.message || '领取失败', icon: 'none' })
} finally {
this.prizeClaimLoading = false
}
},
normalizeNotices(list) {
const arr = this.unwrap(list)
return arr.map((i, idx) => ({