587 lines
30 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="threshold-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" :class="statusClass(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 qualification-panel">
<view class="panel-head">
<text class="panel-title">我的参与资格</text>
<text class="panel-side">{{ qualificationModeLabel(detail.qualification_mode) }}</text>
</view>
<view class="share-guide-card" v-if="showInviteGuide">
<view class="share-guide-head">
<text class="share-guide-title">邀请好友一起冲开奖</text>
<text class="share-guide-badge">裂变加速</text>
</view>
<text class="share-guide-desc">当前还差 {{ crowdGapCount }} 人达到开奖标准分享活动给好友可更快凑齐人数</text>
<view class="share-guide-actions">
<button class="share-action-btn primary" open-type="share">立即邀请好友</button>
<view class="share-action-btn secondary" @tap="copyInviteTip">复制邀请口令</view>
<view class="share-action-btn secondary" @tap="goMyInvites">我的邀请记录</view>
</view>
</view>
<view class="metric-card">
<view class="metric-head">
<text class="metric-title">消费进度</text>
<text class="metric-state" :class="qualification?.spend_qualified ? 'ok' : 'pending'">
{{ qualification?.spend_qualified ? '已达标' : '未达标' }}
</text>
</view>
<text class="metric-value">{{ money(qualification?.current_paid || 0) }} / {{ money(detail.spend_threshold_amount) }}</text>
<view class="progress-bar">
<view class="progress-fill spend" :style="{ width: spendProgressPercent + '%' }"></view>
</view>
</view>
<view class="metric-card">
<view class="metric-head">
<text class="metric-title">邀请进度</text>
<text class="metric-state" :class="qualification?.invite_qualified ? 'ok' : 'pending'">
{{ qualification?.invite_qualified ? '已达标' : '未达标' }}
</text>
</view>
<text class="metric-value">{{ qualification?.effective_invite_count || 0 }} / {{ detail.invite_threshold_count || 0 }} </text>
<view class="progress-bar">
<view class="progress-fill invite" :style="{ width: inviteProgressPercent + '%' }"></view>
</view>
<text class="metric-help" v-if="detail.invite_effective_amount > 0">每位活动开始后新邀请的用户消费满 {{ money(detail.invite_effective_amount) }} 才算 1 位有效邀请</text>
</view>
<view class="qualification-summary">
<text class="qualification-text">{{ qualificationSummary }}</text>
<view class="join-btn" :class="joinButtonClass" @tap="handleJoin">{{ joinButtonText }}</view>
</view>
</view>
<view class="detail-panel crowd-panel">
<view class="panel-head compact">
<text class="panel-title">开奖进度</text>
<text class="panel-side">{{ detail.participant_count || 0 }} / {{ detail.min_participants || 0 }} </text>
</view>
<view class="crowd-progress-bar">
<view class="crowd-progress-fill" :style="{ width: crowdProgressPercent + '%' }"></view>
</view>
<view class="crowd-summary">
<text class="crowd-summary-main">{{ crowdSummary }}</text>
<text class="crowd-summary-sub">{{ crowdHint }}</text>
</view>
</view>
<view class="detail-panel">
<view class="panel-head">
<text class="panel-title">奖品</text>
<text class="panel-side">参与人数 {{ detail.participant_count || 0 }} / 最低开奖 {{ detail.min_participants || 0 }}</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>
<text class="panel-side emphasis">{{ crowdGapText }}</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>
<view class="winner-popup-meta">
<text class="winner-popup-meta-main">当前 {{ participantCount }} / {{ detail.min_participants || 0 }} </text>
<text class="winner-popup-meta-sub">{{ crowdGapText }}</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 {
getMyThresholdActivity,
getThresholdActivity,
joinThresholdActivity,
listThresholdParticipants,
listThresholdWinners
} from '@/api/thresholdActivity'
export default {
data() {
return {
detail: null,
winners: [],
participants: [],
participantsPage: 1,
participantsPageSize: 20,
participantsLoading: false,
winnerPopupVisible: false,
participantsPopupVisible: false,
currentUserId: 0
}
},
computed: {
qualification() {
return this.detail?.qualification_progress || {}
},
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)
})
},
spendProgressPercent() {
const current = Number(this.qualification?.current_paid || 0)
const target = Number(this.detail?.spend_threshold_amount || 0)
if (target <= 0) return 0
return Math.max(0, Math.min(100, Math.round((current / target) * 100)))
},
inviteProgressPercent() {
const current = Number(this.qualification?.effective_invite_count || 0)
const target = Number(this.detail?.invite_threshold_count || 0)
if (target <= 0) return 0
return Math.max(0, Math.min(100, Math.round((current / target) * 100)))
},
crowdProgressPercent() {
const current = Number(this.detail?.participant_count || 0)
const target = Number(this.detail?.min_participants || 0)
if (target <= 0) return 0
return Math.max(0, Math.min(100, Math.round((current / target) * 100)))
},
crowdGapText() {
const target = Number(this.detail?.min_participants || 0)
const current = Number(this.detail?.participant_count || 0)
const gap = Math.max(0, target - current)
if (this.detail?.status === 'aborted') return '本期参与人数未达标,活动已流产'
if (this.detail?.status === 'finished') return '本期已结束'
if (gap <= 0) return '已满足开奖人数条件'
return `还差 ${gap} 人达到开奖标准`
},
crowdGapCount() {
return Math.max(0, Number(this.detail?.min_participants || 0) - Number(this.detail?.participant_count || 0))
},
showInviteGuide() {
return this.detail?.status === 'active' && this.crowdGapCount > 0
},
crowdSummary() {
const current = Number(this.detail?.participant_count || 0)
const target = Number(this.detail?.min_participants || 0)
return `当前已参与 ${current} 人 / 开奖至少需要 ${target}`
},
crowdHint() {
const gap = Math.max(0, Number(this.detail?.min_participants || 0) - Number(this.detail?.participant_count || 0))
if (this.detail?.status === 'aborted') return '本期人数不足,未开奖'
if (this.detail?.status === 'finished') return '本期已开奖结束'
if (gap <= 0) return '已满足开奖人数条件,等待开奖'
return `还差 ${gap} 人即可开奖`
},
qualificationSummary() {
if (this.detail?.joined) return '您已成功参加本期活动'
if (this.detail?.status === 'aborted') return '本期人数不足,活动已流产'
if (this.detail?.status === 'finished') return '活动已开奖结束'
if (this.detail?.can_join) return '已达到参与门槛,可立即报名'
return this.buildPendingHint()
},
joinButtonText() {
if (this.detail?.joined) return '已参加'
if (this.detail?.status === 'aborted') return '已流产'
if (this.detail?.status === 'finished') 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?.can_join && !this.detail?.joined) return 'primary'
if (this.detail?.joined) return 'joined'
if (this.detail?.status === 'aborted') return 'aborted'
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)
const inviteCode = options?.invite_code || options?.inviteCode || ''
if (inviteCode) {
try { uni.setStorageSync('inviter_code', inviteCode) } catch (_) {}
}
this.loadData()
},
onShareAppMessage() {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
const title = this?.detail?.title || '拉新裂变活动'
const crowd = Number(this?.detail?.participant_count || 0)
const min = Number(this?.detail?.min_participants || 0)
const gap = Math.max(0, min - crowd)
return {
title: gap > 0 ? `${title}|还差 ${gap} 人开奖,快来一起冲!` : `${title}|已达开奖人数,快来参加!`,
path: `/pages-user/invite/landing?invite_code=${inviteCode}`,
imageUrl: this?.detail?.cover_image || '/static/logo.png'
}
},
onShareTimeline() {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
const title = this?.detail?.title || '拉新裂变活动'
return {
title,
query: `invite_code=${inviteCode}`,
imageUrl: this?.detail?.cover_image || '/static/logo.png'
}
},
methods: {
async loadData() {
if (!this.id) return
try {
try {
this.detail = await getMyThresholdActivity(this.id)
} catch (_) {
this.detail = await getThresholdActivity(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 listThresholdWinners(this.id, 1, 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 joinThresholdActivity(this.id)
uni.showToast({ title: '参加成功', icon: 'success' })
await this.loadData()
} catch (e) {
uni.showToast({ title: e.message || '参加失败', icon: 'none' })
}
},
goMyInvites() {
uni.navigateTo({ url: '/pages-user/invites/index' })
},
copyInviteTip() {
const title = this.detail?.title || '裂变活动'
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
const text = inviteCode ? `一起参加${title},用我的邀请码 ${inviteCode} 登录后更快凑齐开奖人数!` : `一起参加${title},快来帮我凑齐开奖人数!`
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({ title: '邀请口令已复制', icon: 'success' })
},
fail: () => {
uni.showToast({ title: '复制失败,请手动分享', 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 listThresholdParticipants(this.id, nextPage, 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/threshold/index?mode=history' })
},
buildPendingHint() {
const qualificationMode = this.detail?.qualification_mode
const spendTarget = Number(this.detail?.spend_threshold_amount || 0)
const spendCurrent = Number(this.qualification?.current_paid || 0)
const inviteTarget = Number(this.detail?.invite_threshold_count || 0)
const inviteCurrent = Number(this.qualification?.effective_invite_count || 0)
if (qualificationMode === 'spend_only') {
return `消费还差 ${this.money(Math.max(0, spendTarget - spendCurrent))} 即可参加`
}
if (qualificationMode === 'invite_only') {
return `有效邀请还差 ${Math.max(0, inviteTarget - inviteCurrent)} 人即可参加`
}
const parts = []
if (spendTarget > spendCurrent) parts.push(`消费还差 ${this.money(spendTarget - spendCurrent)}`)
if (inviteTarget > inviteCurrent) parts.push(`有效邀请还差 ${inviteTarget - inviteCurrent}`)
return parts.length ? `${parts.join('')} 即可参加` : '当前未满足参加条件'
},
statusText(status) {
return { active: '进行中', finished: '已结束', aborted: '已流产' }[status] || status || '-'
},
statusClass(status) {
return { active: 'status-active', finished: 'status-finished', aborted: 'status-aborted' }[status] || 'status-finished'
},
typeLabel(type) {
return { daily: '每日裂变', weekly: '每周裂变', monthly: '每月裂变' }[type] || '裂变活动'
},
typeClass(type) {
return { daily: 'type-daily', weekly: 'type-weekly', monthly: 'type-monthly' }[type] || 'type-default'
},
qualificationModeLabel(mode) {
return { spend_only: '消费达标', invite_only: '邀请达标', either: '任一达标' }[mode] || '门槛活动'
},
formatTime(v) {
if (!v) return '-'
return String(v).replace('T', ' ').slice(0, 16)
},
formatAmount(cents) {
return (Number(cents || 0) / 100).toFixed(2)
},
money(v) {
return `¥${(Number(v || 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">
.threshold-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; font-size: 22rpx; font-weight: 800; }
.status-active { background: #dcfce7; color: #16a34a; }
.status-finished { background: #e2e8f0; color: #64748b; }
.status-aborted { background: #fee2e2; color: #dc2626; }
.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-title { font-size: 28rpx; font-weight: 900; color: #1f2937; }
.panel-side { font-size: 22rpx; color: #9ca3af; }
.share-guide-card { margin-top: 18rpx; padding: 24rpx; border-radius: 24rpx; background: linear-gradient(135deg, #fff7ed, #ffedd5); border: 2rpx solid rgba(249, 115, 22, 0.14); }
.share-guide-head { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.share-guide-title { font-size: 28rpx; font-weight: 900; color: #7c2d12; }
.share-guide-badge { padding: 8rpx 18rpx; border-radius: 999rpx; background: rgba(249, 115, 22, 0.12); color: #f97316; font-size: 22rpx; font-weight: 800; }
.share-guide-desc { display: block; margin-top: 14rpx; font-size: 24rpx; line-height: 1.6; color: #9a3412; }
.share-guide-actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
.share-action-btn { flex: 1; min-height: 80rpx; display: flex; align-items: center; justify-content: center; border-radius: 999rpx; font-size: 24rpx; font-weight: 800; box-sizing: border-box; }
button.share-action-btn { padding: 0 24rpx; line-height: 80rpx; }
button.share-action-btn::after { border: none; }
.share-action-btn.primary { background: linear-gradient(135deg, #fb923c, #f97316); color: #fff; }
.share-action-btn.secondary { background: #fff; color: #ea580c; border: 2rpx solid rgba(249, 115, 22, 0.18); }
.metric-card { padding: 22rpx; border-radius: 22rpx; background: #fff7ed; margin-top: 18rpx; }
.crowd-panel { background: linear-gradient(180deg, #fff 0%, #fff7ed 100%); }
.crowd-progress-bar { height: 18rpx; border-radius: 999rpx; background: #fde7cf; overflow: hidden; margin-top: 18rpx; }
.crowd-progress-fill { height: 100%; border-radius: 999rpx; background: linear-gradient(90deg, #f59e0b, #f97316); }
.crowd-summary { display: flex; flex-direction: column; gap: 10rpx; margin-top: 18rpx; }
.crowd-summary-main { font-size: 28rpx; font-weight: 800; color: #1f2937; }
.crowd-summary-sub { font-size: 22rpx; color: #9a3412; }
.metric-head { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.metric-title { font-size: 26rpx; font-weight: 800; color: #1f2937; }
.metric-state { font-size: 22rpx; font-weight: 800; }
.metric-state.ok { color: #16a34a; }
.metric-state.pending { color: #f97316; }
.metric-value { display: block; margin-top: 14rpx; font-size: 30rpx; font-weight: 800; color: #1f2937; }
.metric-help { display: block; margin-top: 12rpx; font-size: 22rpx; color: #6b7280; }
.progress-bar { height: 18rpx; border-radius: 999rpx; background: #fed7aa; overflow: hidden; margin-top: 14rpx; }
.progress-fill { height: 100%; border-radius: 999rpx; }
.progress-fill.spend { background: linear-gradient(90deg, #f97316, #fb7185); }
.progress-fill.invite { background: linear-gradient(90deg, #0ea5e9, #22c55e); }
.qualification-summary { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
.qualification-text { 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.joined { background: #dcfce7; color: #16a34a; }
.join-btn.aborted { background: #fee2e2; color: #dc2626; }
.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-head { align-items: flex-start; }
.participant-head-actions { display: flex; align-items: center; gap: 12rpx; flex-shrink: 0; }
.participant-meta-row { margin-bottom: 16rpx; display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
.panel-side.emphasis { color: #f97316; font-weight: 800; }
.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); }
.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-meta { padding: 16rpx 28rpx 0; display: flex; flex-direction: column; gap: 8rpx; }
.winner-popup-meta-main { font-size: 24rpx; font-weight: 800; color: #1f2937; }
.winner-popup-meta-sub { font-size: 22rpx; color: #f97316; }
.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>