Zuncle 8a3676eb9f fix(activity): 优化福利活动未开始提示
福利活动详情页在开始前显示未开始状态与开始时间提示,避免用户误以为当前可参与。
2026-05-02 23:04:43 +08:00

424 lines
21 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>
<view class="welfare-detail-page" v-if="detail">
<image v-if="detail.cover_image" class="detail-cover" :src="detail.cover_image" mode="aspectFill" />
<view v-else class="detail-cover empty">暂无活动图片</view>
<view class="detail-panel hero-panel">
<view class="hero-head">
<text class="detail-title">{{ detail.title }}</text>
<text class="detail-type" :class="typeClass(detail.type)">{{ typeLabel(detail.type) }}</text>
</view>
<text class="detail-status">{{ statusText(detail.status) }}</text>
<text class="detail-time">开奖时间{{ formatTime(detail.draw_time) }}</text>
<text class="detail-desc" v-if="detail.description">{{ detail.description }}</text>
</view>
<view class="detail-panel join-panel">
<view class="panel-head">
<text class="panel-title">我的参与进度</text>
<text class="panel-side">{{ progressText }}</text>
</view>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
</view>
<view class="progress-foot">
<text class="progress-hint">{{ progressHint }}</text>
<view
class="join-btn"
:class="joinButtonClass"
@tap="handleJoin"
>
{{ joinButtonText }}
</view>
</view>
</view>
<view class="detail-panel">
<view class="panel-head">
<text class="panel-title">奖品</text>
<text v-if="sortedPrizes.length > 1" class="panel-side">按价格从高到低</text>
</view>
<scroll-view v-if="sortedPrizes.length" scroll-x class="prize-scroll" show-scrollbar="false">
<view class="prize-track">
<view v-for="prize in sortedPrizes" :key="prize.id" class="prize-item">
<image v-if="prize.image" class="prize-image" :src="prize.image" mode="aspectFill" />
<view v-else class="prize-image empty">{{ prizePlaceholderText(prize) }}</view>
<text class="prize-name">{{ prize.name }}</text>
<text class="prize-price">参考价 ¥{{ formatAmount(prizePrice(prize)) }}</text>
<text class="prize-count">x{{ prize.quantity }}</text>
</view>
</view>
</scroll-view>
<view v-else class="empty-text">暂无奖品</view>
</view>
<view class="detail-panel">
<view class="panel-head participant-head">
<text class="panel-title">参与玩家</text>
<view class="participant-head-actions">
<view class="head-action-btn overview" @tap="openWinnerOverview">中奖概览</view>
<view v-if="showMoreParticipants" class="head-action-btn" @tap="openParticipantsPopup">查看更多</view>
</view>
</view>
<view class="participant-meta-row">
<text class="panel-side"> {{ participantCount }} </text>
</view>
<view class="avatar-row" v-if="previewParticipants.length">
<view
v-for="player in previewParticipants"
:key="player.user_id"
class="avatar-item"
:class="{ mine: isSelfParticipant(player) }"
>
<image class="participant-avatar" :src="player.avatar || '/static/logo.png'" mode="aspectFill" />
</view>
</view>
<view v-else class="empty-text">暂无参与玩家</view>
</view>
<view class="detail-panel history-entry" @tap="goHistory">
<view>
<text class="panel-title">查看往期活动</text>
<text class="history-subtitle">浏览已结束的福利活动与中奖信息</text>
</view>
</view>
<view v-if="winnerPopupVisible" class="winner-popup-overlay" @touchmove.stop.prevent>
<view class="winner-popup-mask" @tap="winnerPopupVisible = false"></view>
<view class="winner-popup-panel" @tap.stop>
<view class="winner-popup-head">
<text class="winner-popup-title">当前活动中奖概览</text>
<text class="winner-popup-close" @tap="winnerPopupVisible = false">×</text>
</view>
<scroll-view scroll-y class="winner-popup-list">
<view v-if="winnerOverviewList.length">
<view v-for="item in winnerOverviewList" :key="item.id" class="winner-popup-item">
<image v-if="item.prize_image" class="winner-popup-image" :src="item.prize_image" mode="aspectFill" />
<view v-else class="winner-popup-image empty">{{ rewardPlaceholderText(item) }}</view>
<view class="winner-popup-info">
<text class="winner-popup-name">{{ item.nickname || ('用户' + item.user_id) }} · {{ item.prize_name }}</text>
<text class="winner-popup-price">参考价 ¥{{ formatAmount(winnerPrice(item)) }}</text>
<text class="winner-popup-time">{{ formatTime(item.created_at) }}</text>
</view>
</view>
</view>
<view v-else class="empty-text">还未开奖</view>
</scroll-view>
</view>
</view>
<view v-if="participantsPopupVisible" class="winner-popup-overlay" @touchmove.stop.prevent>
<view class="winner-popup-mask" @tap="participantsPopupVisible = false"></view>
<view class="winner-popup-panel" @tap.stop>
<view class="winner-popup-head">
<text class="winner-popup-title">全部参与玩家</text>
<text class="winner-popup-close" @tap="participantsPopupVisible = false">×</text>
</view>
<scroll-view scroll-y class="winner-popup-list">
<view v-if="allParticipants.length">
<view v-for="player in allParticipants" :key="player.user_id" class="winner-popup-item participant-popup-item">
<image class="winner-popup-image participant-popup-avatar" :src="player.avatar || '/static/logo.png'" mode="aspectFill" />
<view class="winner-popup-info">
<text class="winner-popup-name">{{ player.nickname || ('用户' + player.user_id) }}</text>
<text class="winner-popup-time" :class="{ 'self-tag': isSelfParticipant(player) }">
{{ isSelfParticipant(player) ? '我已参与' : '活动参与玩家' }}
</text>
</view>
</view>
</view>
<view v-else class="empty-text">暂无参与玩家</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
import { request, authRequest } from '@/utils/request.js'
export default {
data() {
return {
detail: null,
winners: [],
participants: [],
participantsPage: 1,
participantsPageSize: 20,
participantsLoading: false,
winnerPopupVisible: false,
participantsPopupVisible: false,
currentUserId: 0
}
},
computed: {
sortedPrizes() {
const prizes = Array.isArray(this.detail?.prizes) ? [...this.detail.prizes] : []
return prizes.sort((a, b) => {
const aPrice = this.prizePrice(a)
const bPrice = this.prizePrice(b)
if (aPrice !== bPrice) return bPrice - aPrice
return Number(a.sort || 0) - Number(b.sort || 0)
})
},
progressPercent() {
const current = Number(this.detail?.current_paid || 0)
const target = Number(this.detail?.threshold_amount || 0)
if (target <= 0) return this.detail?.joined ? 100 : 0
return Math.max(0, Math.min(100, Math.round((current / target) * 100)))
},
progressText() {
const current = this.formatAmount(this.detail?.current_paid || 0)
const target = this.formatAmount(this.detail?.threshold_amount || 0)
return `${this.windowLabel(this.detail?.type)}消费 ${current}/${target}`
},
progressHint() {
if (this.detail?.joined) return '您已成功参加本期活动'
if (this.detail?.can_join) return '已达参与门槛,可立即参加活动'
const startTime = this.detail?.start_time ? new Date(this.detail.start_time).getTime() : 0
if (startTime && startTime > Date.now()) {
return `活动将于 ${this.formatTime(this.detail.start_time)} 开始`
}
const target = Number(this.detail?.threshold_amount || 0)
const current = Number(this.detail?.current_paid || 0)
if (target > current) {
return `还差 ¥${this.formatAmount(target - current)} 即可参加`
}
return '当前未满足参加条件'
},
joinButtonText() {
if (this.detail?.joined) return '已参加'
const startTime = this.detail?.start_time ? new Date(this.detail.start_time).getTime() : 0
if (startTime && startTime > Date.now()) return '未开始'
if (this.detail?.can_join) return '参加活动'
return '未达门槛'
},
joinButtonClass() {
if (this.detail?.joined) return 'disabled'
if (this.detail?.can_join) return 'primary'
return 'muted'
},
participantCount() {
return Number(this.detail?.participant_count || this.participants.length || 0)
},
previewParticipants() {
return this.sortedParticipants.slice(0, 6)
},
sortedParticipants() {
const list = Array.isArray(this.participants) ? [...this.participants] : []
if (!this.currentUserId) return list
return list.sort((a, b) => {
const aSelf = Number(a?.user_id || 0) === this.currentUserId ? 1 : 0
const bSelf = Number(b?.user_id || 0) === this.currentUserId ? 1 : 0
return bSelf - aSelf
})
},
showMoreParticipants() {
return this.participantCount > this.previewParticipants.length
},
allParticipants() {
return this.sortedParticipants
},
winnerOverviewList() {
return Array.isArray(this.winners) ? this.winners : []
},
},
onLoad(options) {
this.id = Number(options?.id || 0)
this.loadData()
},
methods: {
async loadData() {
if (!this.id) return
try {
try {
this.detail = await authRequest({ url: `/api/app/welfare-activities/${this.id}/my`, suppressAuthModal: true })
} catch (_) {
this.detail = await request({ url: `/api/app/welfare-activities/${this.id}` })
}
if (!this.detail.cover_image && this.detail.prizes && this.detail.prizes.length) {
this.detail.cover_image = this.detail.prizes.find((item) => item.image)?.image || ''
}
this.participants = Array.isArray(this.detail?.participants) ? this.detail.participants : []
this.currentUserId = Number(uni.getStorageSync('user_id') || 0)
this.participantsPage = 1
const winnersRes = await request({ url: `/api/app/welfare-activities/${this.id}/winners?page=1&page_size=100` })
this.winners = winnersRes?.list || []
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
},
async handleJoin() {
if (this.detail?.joined || !this.detail?.can_join) return
try {
await authRequest({ url: `/api/app/welfare-activities/${this.id}/join`, method: 'POST' })
uni.showToast({ title: '参加成功', icon: 'success' })
await this.loadData()
} catch (e) {
uni.showToast({ title: e.message || '参加失败', icon: 'none' })
}
},
async loadMoreParticipants() {
if (this.participantsLoading) return
if (this.participants.length >= this.participantCount) return
this.participantsLoading = true
try {
const nextPage = this.participantsPage + 1
const res = await request({
url: `/api/app/welfare-activities/${this.id}/participants?page=${nextPage}&page_size=${this.participantsPageSize}`
})
const list = Array.isArray(res?.list) ? res.list : []
const seen = new Set(this.participants.map(item => String(item.user_id)))
list.forEach(item => {
const key = String(item.user_id)
if (!seen.has(key)) {
seen.add(key)
this.participants.push(item)
}
})
this.participantsPage = nextPage
} catch (e) {
uni.showToast({ title: e.message || '加载参与玩家失败', icon: 'none' })
} finally {
this.participantsLoading = false
}
},
async openParticipantsPopup() {
this.participantsPopupVisible = true
await this.loadAllParticipants()
},
async loadAllParticipants() {
if (this.participantsLoading) return
while (this.participants.length < this.participantCount) {
const prevLength = this.participants.length
await this.loadMoreParticipants()
if (this.participants.length === prevLength) break
}
},
openWinnerOverview() {
this.winnerPopupVisible = true
},
goHistory() {
uni.navigateTo({ url: '/pages-activity/activity/welfare/index?mode=finished' })
},
statusText(status) {
return { active: '进行中', finished: '已结束' }[status] || status || '-'
},
typeLabel(type) {
return { daily: '每日福利', weekly: '每周福利', monthly: '每月福利' }[type] || '福利活动'
},
typeClass(type) {
return {
daily: 'type-daily',
weekly: 'type-weekly',
monthly: 'type-monthly'
}[type] || 'type-default'
},
windowLabel(type) {
return { daily: '每日', weekly: '每周', monthly: '每月' }[type] || '活动'
},
formatTime(v) {
if (!v) return '-'
return String(v).replace('T', ' ').slice(0, 16)
},
formatAmount(cents) {
return (Number(cents || 0) / 100).toFixed(2)
},
prizePrice(prize) {
return Number(prize?.price_cents ?? prize?.price ?? prize?.product_price ?? prize?.price_snapshot_cents ?? 0)
},
winnerPrice(item) {
return Number(item?.price_cents ?? item?.price ?? item?.product_price ?? item?.price_snapshot_cents ?? 0)
},
rewardPlaceholderText(item) {
const type = String(item?.reward_type || '').toLowerCase()
if (type === 'coupon') return '优惠券'
if (type === 'item_card') return '道具卡'
return '奖品'
},
prizePlaceholderText(prize) {
return this.rewardPlaceholderText(prize)
},
isSelfParticipant(player) {
return Number(player?.user_id || 0) > 0 && Number(player?.user_id || 0) === this.currentUserId
}
}
}
</script>
<style lang="scss">
.welfare-detail-page { min-height: 100vh; padding-bottom: 40rpx; background: #fff7ed; }
.detail-cover { width: 100%; height: 420rpx; display: block; background: #f3f4f6; }
.detail-cover.empty { display: flex; align-items: center; justify-content: center; color: #9ca3af; }
.detail-panel { margin: 24rpx 28rpx 0; padding: 28rpx; border-radius: 28rpx; background: #fff; box-shadow: 0 12rpx 30rpx rgba(0,0,0,.06); }
.hero-head { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
.detail-title { display: block; font-size: 38rpx; font-weight: 900; color: #1f2937; flex: 1; }
.detail-type { display: inline-flex; align-items: center; padding: 10rpx 20rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 800; }
.type-daily { background: rgba(249, 115, 22, .12); color: #f97316; }
.type-weekly { background: rgba(239, 68, 68, .12); color: #ef4444; }
.type-monthly { background: linear-gradient(135deg, #a855f7, #ec4899, #f59e0b); color: #fff; }
.type-default { background: rgba(148, 163, 184, .12); color: #64748b; }
.detail-status { display: inline-block; margin-top: 16rpx; padding: 8rpx 20rpx; border-radius: 999rpx; background: #fef3c7; color: #b45309; font-size: 22rpx; font-weight: 800; }
.detail-time, .detail-desc { display: block; margin-top: 16rpx; font-size: 24rpx; color: #4b5563; }
.panel-head { margin-bottom: 18rpx; display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.panel-head.compact { margin-bottom: 10rpx; }
.panel-title { font-size: 28rpx; font-weight: 900; color: #1f2937; }
.panel-side { font-size: 22rpx; color: #9ca3af; }
.progress-bar { height: 18rpx; border-radius: 999rpx; background: #fed7aa; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 999rpx; background: linear-gradient(90deg, #f97316, #fb7185); }
.progress-foot { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-top: 20rpx; }
.progress-hint { flex: 1; font-size: 22rpx; color: #6b7280; }
.join-btn { min-width: 180rpx; padding: 18rpx 28rpx; text-align: center; border-radius: 999rpx; font-size: 24rpx; font-weight: 800; }
.join-btn.primary { background: linear-gradient(135deg, #fb923c, #f97316); color: #fff; }
.join-btn.disabled { background: #dcfce7; color: #16a34a; }
.join-btn.muted { background: #f3f4f6; color: #9ca3af; }
.prize-scroll { white-space: nowrap; }
.prize-track { display: inline-flex; gap: 20rpx; }
.prize-item { width: 260rpx; flex-shrink: 0; }
.prize-image { width: 260rpx; height: 180rpx; border-radius: 20rpx; background: #f3f4f6; }
.prize-image.empty { display: flex; align-items: center; justify-content: center; color: #9ca3af; }
.prize-name { display: -webkit-box; margin-top: 12rpx; min-height: 68rpx; font-size: 24rpx; font-weight: 700; color: #1f2937; white-space: normal; word-break: break-all; overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.prize-price { display: block; margin-top: 8rpx; font-size: 22rpx; color: #f97316; line-height: 1.4; }
.prize-count { display: block; margin-top: 6rpx; font-size: 22rpx; color: #6b7280; line-height: 1.4; }
.participant-preview { display: flex; align-items: center; gap: 12rpx; overflow: hidden; }
.participant-head { align-items: flex-start; }
.participant-head-actions { display: flex; align-items: center; gap: 12rpx; flex-shrink: 0; }
.participant-meta-row { margin-bottom: 16rpx; }
.avatar-row { display: flex; align-items: center; gap: 12rpx; overflow-x: auto; padding-bottom: 4rpx; }
.avatar-item { position: relative; flex-shrink: 0; }
.avatar-item.mine .participant-avatar { border-color: #fb923c; box-shadow: 0 0 0 4rpx rgba(251, 146, 60, 0.16); }
.participant-avatar { width: 68rpx; height: 68rpx; border-radius: 50%; background: #f3f4f6; border: 4rpx solid #fff; box-shadow: 0 8rpx 18rpx rgba(0,0,0,.08); }
.head-action-btn { flex-shrink: 0; padding: 14rpx 20rpx; border-radius: 999rpx; background: #fff7ed; color: #f97316; font-size: 22rpx; font-weight: 800; border: 2rpx solid rgba(249,115,22,.18); }
.head-action-btn.overview { background: linear-gradient(135deg, #fff7ed, #ffedd5); }
.more-count { min-width: 68rpx; height: 68rpx; padding: 0 16rpx; border-radius: 34rpx; background: #fff7ed; color: #f97316; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 800; }
.participant-list { display: none; }
.participant-overview-card, .participant-card { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 20rpx; border-radius: 22rpx; background: #fff7ed; }
.overview-icon { width: 76rpx; height: 76rpx; border-radius: 50%; background: linear-gradient(135deg, #fde68a, #fb7185); display: flex; align-items: center; justify-content: center; font-size: 34rpx; flex-shrink: 0; }
.participant-main { display: flex; align-items: center; gap: 14rpx; flex: 1; min-width: 0; }
.participant-card-avatar { width: 76rpx; height: 76rpx; border-radius: 50%; background: #f3f4f6; }
.participant-card-info { flex: 1; min-width: 0; }
.participant-name { display: block; font-size: 26rpx; font-weight: 800; color: #1f2937; }
.participant-sub { display: block; margin-top: 8rpx; font-size: 22rpx; color: #6b7280; }
.winner-btn { padding: 14rpx 20rpx; border-radius: 999rpx; background: #fff; color: #f97316; font-size: 22rpx; font-weight: 800; flex-shrink: 0; }
.overview-btn { min-width: 112rpx; text-align: center; }
.expand-btn { display: none; }
.empty-text { font-size: 24rpx; color: #9ca3af; }
.history-entry { display: flex; justify-content: space-between; align-items: center; }
.history-subtitle { display: block; margin-top: 10rpx; font-size: 22rpx; color: #9ca3af; }
.winner-popup-overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; }
.winner-popup-mask { position: absolute; inset: 0; background: rgba(0,0,0,.48); }
.winner-popup-panel { position: relative; width: 88%; max-height: 72vh; background: #fff; border-radius: 28rpx; overflow: hidden; box-shadow: 0 18rpx 50rpx rgba(0,0,0,.18); }
.winner-popup-head { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 26rpx 28rpx; border-bottom: 2rpx solid #f3f4f6; }
.winner-popup-title { flex: 1; font-size: 28rpx; font-weight: 900; color: #1f2937; }
.winner-popup-close { font-size: 42rpx; color: #9ca3af; line-height: 1; }
.winner-popup-list { max-height: 54vh; padding: 24rpx 28rpx; }
.winner-popup-item { display: flex; gap: 16rpx; align-items: center; padding: 18rpx 0; }
.participant-popup-item { align-items: center; }
.participant-popup-avatar { border-radius: 50%; }
.self-tag { color: #f97316; font-weight: 800; }
.winner-popup-image { width: 96rpx; height: 96rpx; border-radius: 20rpx; background: #f3f4f6; flex-shrink: 0; }
.winner-popup-image.empty { display: flex; align-items: center; justify-content: center; color: #9ca3af; }
.winner-popup-info { flex: 1; min-width: 0; }
.winner-popup-name { display: block; font-size: 26rpx; font-weight: 800; color: #1f2937; }
.winner-popup-price, .winner-popup-time { display: block; margin-top: 8rpx; font-size: 22rpx; color: #6b7280; }
</style>