feat(activity): 新增小程序奖品领取弹窗
新增首页承载的奖品领取弹窗与领取接口接入,支持待领取检查、会话静默关闭与领取操作展示。
This commit is contained in:
parent
8a3676eb9f
commit
d530ec11e7
3
App.vue
3
App.vue
@ -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
9
api/prizeClaim.js
Normal 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' })
|
||||
}
|
||||
289
components/activity/PrizeClaimPopup.vue
Normal file
289
components/activity/PrizeClaimPopup.vue
Normal 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>
|
||||
@ -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'
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user