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) }
|
try { uni.setStorageSync('inviter_code', options.query.invite_code) } catch (e) { console.error('Save invite code failed', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载公开配置 (如订阅消息模板ID)
|
|
||||||
getPublicConfig().then(res => {
|
getPublicConfig().then(res => {
|
||||||
if (res && res.subscribe_templates) {
|
if (res && res.subscribe_templates) {
|
||||||
console.log('Loaded public config:', res)
|
console.log('Loaded public config:', res)
|
||||||
@ -20,8 +19,6 @@ export default {
|
|||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.warn('Failed to load public config:', err)
|
console.warn('Failed to load public config:', err)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 抖音平台现在也显示首页,不再需要强制跳转
|
|
||||||
},
|
},
|
||||||
onShow: function() {
|
onShow: function() {
|
||||||
console.log('App Show')
|
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 RewardsPreview } from './RewardsPreview.vue'
|
||||||
export { default as RewardsPopup } from './RewardsPopup.vue'
|
export { default as RewardsPopup } from './RewardsPopup.vue'
|
||||||
export { default as RecordsList } from './RecordsList.vue'
|
export { default as RecordsList } from './RecordsList.vue'
|
||||||
|
export { default as PrizeClaimPopup } from './PrizeClaimPopup.vue'
|
||||||
|
|||||||
@ -140,19 +140,30 @@
|
|||||||
<!-- 底部垫高 - 避开TabBar -->
|
<!-- 底部垫高 - 避开TabBar -->
|
||||||
<view style="height: 140rpx"></view>
|
<view style="height: 140rpx"></view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
|
<PrizeClaimPopup
|
||||||
|
v-model:visible="prizeClaimVisible"
|
||||||
|
:activity="prizeClaimActivity"
|
||||||
|
:loading="prizeClaimLoading"
|
||||||
|
@close="handlePrizeClaimClose"
|
||||||
|
@claim="handlePrizeClaim"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { authRequest, request } from '../../utils/request.js'
|
import { authRequest, request } from '../../utils/request.js'
|
||||||
|
import { getPendingPrizeGrantActivity, claimPrizeGrantActivity } from '@/api/prizeClaim'
|
||||||
import SplashScreen from '@/components/SplashScreen.vue'
|
import SplashScreen from '@/components/SplashScreen.vue'
|
||||||
|
import PrizeClaimPopup from '@/components/activity/PrizeClaimPopup.vue'
|
||||||
// #ifdef MP-TOUTIAO
|
// #ifdef MP-TOUTIAO
|
||||||
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
|
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
SplashScreen
|
SplashScreen,
|
||||||
|
PrizeClaimPopup
|
||||||
// #ifdef MP-TOUTIAO
|
// #ifdef MP-TOUTIAO
|
||||||
, customTabBarToutiao
|
, customTabBarToutiao
|
||||||
// #endif
|
// #endif
|
||||||
@ -166,7 +177,12 @@ export default {
|
|||||||
bannerIndex: 0,
|
bannerIndex: 0,
|
||||||
isHomeLoading: false,
|
isHomeLoading: false,
|
||||||
swiperAutoplay: true,
|
swiperAutoplay: true,
|
||||||
swiperKey: 0
|
swiperKey: 0,
|
||||||
|
prizeClaimVisible: false,
|
||||||
|
prizeClaimLoading: false,
|
||||||
|
prizeClaimActivity: null,
|
||||||
|
prizeClaimChecking: false,
|
||||||
|
prizeClaimLastCheckAt: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -224,6 +240,7 @@ export default {
|
|||||||
if (this.activities.length === 0 && !this.isHomeLoading) {
|
if (this.activities.length === 0 && !this.isHomeLoading) {
|
||||||
this.loadHomeData()
|
this.loadHomeData()
|
||||||
}
|
}
|
||||||
|
this.checkPrizeGrantActivity()
|
||||||
},
|
},
|
||||||
onHide() {
|
onHide() {
|
||||||
this.swiperAutoplay = false
|
this.swiperAutoplay = false
|
||||||
@ -262,6 +279,55 @@ export default {
|
|||||||
const fn = token ? authRequest : request
|
const fn = token ? authRequest : request
|
||||||
return fn({ url })
|
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) {
|
normalizeNotices(list) {
|
||||||
const arr = this.unwrap(list)
|
const arr = this.unwrap(list)
|
||||||
return arr.map((i, idx) => ({
|
return arr.map((i, idx) => ({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user