1559 lines
43 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="page-wrapper">
<view class="bg-decoration">
<view class="orb orb-1"></view>
<view class="orb orb-2"></view>
</view>
<view class="page-bg">
<image class="bg-image" :src="coverUrl" mode="aspectFill" />
<view class="bg-mask"></view>
</view>
<scroll-view class="main-scroll" scroll-y>
<view class="header-card animate-enter">
<image class="header-cover" :src="coverUrl" mode="aspectFill" />
<view class="header-info">
<view class="header-title">{{ detail.name || detail.title || '无限赏活动' }}</view>
<view class="header-price-row">
<text class="price-symbol">¥</text>
<text class="price-num">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text>
<text class="price-unit">/</text>
</view>
<view class="header-tags">
<view class="tag-item">公开透明</view>
<view class="tag-item">随机掉落</view>
</view>
</view>
<view class="header-actions">
<view class="action-btn" @tap="showRules">
<view class="action-icon rules-icon"></view>
<text class="action-label">规则</text>
</view>
<view class="action-btn" @tap="goCabinet">
<view class="action-icon cabinet-icon"></view>
<text class="action-label">盒柜</text>
</view>
</view>
</view>
<view class="section-container animate-enter stagger-1">
<view class="modern-tabs">
<view class="tab-item" :class="{ active: tabActive === 'pool' }" @tap="tabActive = 'pool'">
本机奖池
<view v-if="tabActive === 'pool'" class="active-dot"></view>
</view>
<view class="tab-item" :class="{ active: tabActive === 'records' }" @tap="tabActive = 'records'">
购买记录
<view v-if="tabActive === 'records'" class="active-dot"></view>
</view>
</view>
<view v-show="tabActive === 'pool'">
<view class="section-header">
<text class="section-title">奖池配置</text>
<text class="section-more" @tap="openRewardsPopup">查看全部</text>
</view>
<view v-if="rewardGroups.length > 0">
<view class="prize-level-row" v-for="group in rewardGroups" :key="group.level">
<view class="level-header-row">
<view class="level-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">{{ group.level }}</view>
<text class="level-prob">总概率 {{ group.totalPercent }}%</text>
</view>
<scroll-view class="preview-scroll" scroll-x>
<view class="preview-item" v-for="(item, idx) in group.rewards" :key="item.id || idx">
<view class="prize-tag" :class="{ 'tag-boss': item.boss }">{{ item.boss ? 'BOSS' : group.level }}</view>
<image class="preview-img" :src="item.image" mode="aspectFill" />
<view class="preview-name">{{ item.title }}</view>
</view>
</scroll-view>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无奖池配置</text>
</view>
</view>
<view v-show="tabActive === 'records'">
<view class="records-list" v-if="winRecords.length">
<view v-for="(it, idx) in winRecords" :key="it.id" class="record-item">
<image class="record-img" :src="it.image" mode="aspectFill" />
<view class="record-info">
<view class="record-title">{{ it.title }}</view>
<view class="record-meta">
<text class="record-count">x{{ it.count }}</text>
</view>
</view>
</view>
</view>
<view class="empty-state" v-else>
<text class="empty-icon">📝</text>
<text class="empty-text">暂无购买记录</text>
</view>
</view>
</view>
<view style="height: 220rpx;"></view>
</scroll-view>
</view>
<!-- 底部多档位抽赏按钮 -->
<view class="bottom-actions">
<button class="tier-btn" @tap="openPayment(1)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 1).toFixed(2) }}</text>
<text class="tier-label">抽1发</text>
</button>
<button class="tier-btn" @tap="openPayment(3)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 3).toFixed(2) }}</text>
<text class="tier-label">抽3发</text>
</button>
<button class="tier-btn" @tap="openPayment(5)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 5).toFixed(2) }}</text>
<text class="tier-label">抽5发</text>
</button>
<button class="tier-btn tier-hot" @tap="openPayment(10)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 10).toFixed(2) }}</text>
<text class="tier-label">抽10发</text>
</button>
</view>
<view v-if="rewardsVisible" class="rewards-overlay" @touchmove.stop.prevent>
<view class="rewards-mask" @tap="closeRewardsPopup"></view>
<view class="rewards-panel" @tap.stop>
<view class="rewards-header">
<text class="rewards-title">{{ currentIssueTitle }} · 奖池与概率</text>
<text class="rewards-close" @tap="closeRewardsPopup">×</text>
</view>
<scroll-view scroll-y class="rewards-list">
<view v-if="rewardGroups.length > 0">
<view class="rewards-group-v2" v-for="group in rewardGroups" :key="group.level">
<view class="group-header-row">
<text class="group-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">{{ group.level }}</text>
<text class="group-total-prob">该档总概率 {{ group.totalPercent }}%</text>
</view>
<view v-for="(item, idx) in group.rewards" :key="item.id || idx" class="rewards-item">
<image class="rewards-thumb" :src="item.image" mode="aspectFill" />
<view class="rewards-info">
<view class="rewards-name-row">
<text class="rewards-name">{{ item.title || '-' }}</text>
<view class="rewards-tag" v-if="item.boss">BOSS</view>
</view>
<text class="rewards-percent">单项概率 {{ formatPercent(item.percent) }}</text>
</view>
</view>
</view>
</view>
<view v-else class="rewards-empty">暂无奖池数据</view>
</scroll-view>
</view>
</view>
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
<view class="flip-mask" @tap="closeFlip"></view>
<view class="flip-content" @tap.stop>
<FlipGrid ref="flipRef" :rewards="currentIssueRewards" :controls="false" />
<button class="overlay-close" @tap="closeFlip">关闭</button>
</view>
</view>
<PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="coupons"
:propCards="propCards"
@confirm="onPaymentConfirm"
/>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import FlipGrid from '../../../components/FlipGrid.vue'
import { onLoad } from '@dcloudio/uni-app'
import PaymentPopup from '../../../components/PaymentPopup.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, joinLottery, createWechatOrder, getLotteryResult, getItemCards, getUserCoupons, getIssueDrawLogs } from '../../../api/appUser'
const detail = ref({})
const statusText = ref('')
const rewardsVisible = ref(false)
const tabActive = ref('pool')
const winRecords = ref([])
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
const selectedIssueIndex = ref(0)
const showIssues = computed(() => (detail.value && detail.value.status !== 2))
const activityId = ref('')
const drawLoading = ref(false)
const currentIssueRewards = computed(() => (currentIssueId.value && rewardsMap.value[currentIssueId.value]) ? rewardsMap.value[currentIssueId.value] : [])
const coverUrl = computed(() => cleanUrl(detail.value && (detail.value.image || detail.value.banner || '')))
const currentIssueTitle = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
// 不显示“期数”,优先显示标题,兜底显示“奖池”
return (cur && (cur.title || '奖池')) || '-'
})
const points = ref(0)
const flipRef = ref(null)
const showFlip = ref(false)
const rewardsForPopup = computed(() => {
const arr = currentIssueRewards.value || []
return Array.isArray(arr) ? arr : []
})
const rewardGroups = computed(() => {
const groups = {}
currentIssueRewards.value.forEach(item => {
const level = item.level || '赏'
if (!groups[level]) groups[level] = []
groups[level].push(item)
})
return Object.keys(groups).sort((a, b) => {
if (a === 'BOSS') return -1
if (b === 'BOSS') return 1
// Alphabetical sort (A, B, C...)
return a.localeCompare(b)
}).map(key => {
const rewards = groups[key]
const total = rewards.reduce((sum, item) => sum + (Number(item.percent) || 0), 0)
return {
level: key,
rewards: rewards,
totalPercent: total.toFixed(1)
}
})
})
const paymentVisible = ref(false)
const paymentAmount = ref('0.00')
const coupons = ref([])
const propCards = ref([])
const pendingCount = ref(1)
const selectedCoupon = ref(null)
const selectedCard = ref(null)
const pricePerDrawYuan = computed(() => ((Number(detail.value.price_draw || 0) / 100) || 0))
function statusToText(s) {
if (s === 1) return '进行中'
if (s === 0) return '未开始'
if (s === 2) return '已结束'
return String(s || '')
}
function formatPercent(v) {
const n = Number(v)
if (!Number.isFinite(n)) return '0%'
return `${n}%`
}
function levelToAlpha(level) {
if (level === 'BOSS') return 'BOSS'
const n = Number(level)
if (isNaN(n) || n <= 0) return String(level || '赏')
// 1 -> A, 2 -> B ... 26 -> Z
// Only handle up to 26 levels for now as it's rare to have more
return String.fromCharCode(64 + n)
}
function openRewardsPopup() {
rewardsVisible.value = true
}
function closeRewardsPopup() {
rewardsVisible.value = false
}
function showRules() {
uni.showModal({
title: '活动规则',
content: detail.value.rules || '1. 选择档位进行抽赏\n2. 每次抽赏随机获得奖品\n3. 奖池与概率以页面展示为准',
showCancel: false
})
}
function goCabinet() {
uni.switchTab({ url: '/pages/cabinet/index' })
}
async function fetchDetail(id) {
const data = await getActivityDetail(id)
detail.value = data || {}
statusText.value = statusToText(detail.value.status)
const title = String(detail.value.name || detail.value.title || '无限赏')
try { uni.setNavigationBarTitle({ title }) } catch (_) {}
}
function unwrap(list) {
if (Array.isArray(list)) return list
const obj = list || {}
const data = obj.data || {}
const arr = obj.list || obj.items || data.list || data.items || data
return Array.isArray(arr) ? arr : []
}
function normalizeIssues(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? '',
no: i.no ?? i.index ?? i.issue_no ?? i.issue_number ?? null,
status_text: i.status_text ?? (i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : i.status === 2 ? '已结束' : '')
}))
}
function cleanUrl(u) {
const s = String(u || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'\"]/g, '').trim()
}
function truthy(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
if (!s) return false
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss'
}
function detectBoss(i) {
return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag)
}
function normalizeRewards(list) {
const arr = unwrap(list)
const items = arr.map((i, idx) => ({
id: i.product_id ?? i.id ?? String(idx),
title: i.name ?? i.title ?? '',
image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''),
weight: Number(i.weight) || 0,
boss: detectBoss(i),
level: levelToAlpha(i.prize_level ?? i.level ?? (detectBoss(i) ? 'BOSS' : '赏'))
}))
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
const enriched = items.map(it => ({
...it,
percent: total > 0 ? Math.round((it.weight / total) * 1000) / 10 : 0
}))
enriched.sort((a, b) => (b.percent - a.percent))
return enriched
}
function isFresh(ts) {
const now = Date.now()
const v = Number(ts || 0)
return now - v < 24 * 60 * 60 * 1000
}
function getRewardCache() {
const obj = uni.getStorageSync('reward_cache_v1') || {}
return typeof obj === 'object' && obj ? obj : {}
}
async function fetchRewardsForIssues(activityId) {
const list = issues.value || []
const cache = getRewardCache()
const act = cache[activityId] || {}
const toFetch = []
list.forEach(it => {
const c = act[it.id]
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
rewardsMap.value = { ...(rewardsMap.value || {}), [it.id]: c.value }
} else {
toFetch.push(it)
}
})
if (!toFetch.length) return
const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id))
const results = await Promise.allSettled(promises)
const nextAct = { ...act }
results.forEach((res, i) => {
const issueId = toFetch[i] && toFetch[i].id
if (!issueId) return
const value = res.status === 'fulfilled' ? normalizeRewards(res.value) : []
rewardsMap.value = { ...(rewardsMap.value || {}), [issueId]: value }
nextAct[issueId] = { value, ts: Date.now() }
})
cache[activityId] = nextAct
uni.setStorageSync('reward_cache_v1', cache)
}
async function fetchIssues(id) {
const data = await getActivityIssues(id)
issues.value = normalizeIssues(data)
const latestId = pickLatestIssueId(issues.value)
setSelectedById(latestId)
await fetchRewardsForIssues(id)
// 获取购买记录
if (currentIssueId.value) {
fetchWinRecords(id, currentIssueId.value)
}
}
function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
let maxNo = -Infinity
arr.forEach(i => {
const n = Number(i.no)
if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) {
maxNo = n
latest = i.id
}
})
return latest || (arr[0] && arr[0].id) || ''
}
function setSelectedById(id) {
const arr = issues.value || []
const idx = Math.max(0, arr.findIndex(x => x && x.id === id))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onIssueChange(e) {
// deprecated picker
}
function prevIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value - 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function nextIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value + 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
async function fetchWinRecords(actId, issId) {
if (!actId || !issId) return
try {
const res = await getIssueDrawLogs(actId, issId)
const list = (res && res.list) || (Array.isArray(res) ? res : [])
// 聚合同一奖品的记录
const aggregate = {}
list.forEach(it => {
const key = it.reward_id || it.id
if (!aggregate[key]) {
aggregate[key] = {
id: key,
title: it.reward_name || it.title || it.name || '-',
image: it.reward_image || it.image || '',
count: 0
}
}
aggregate[key].count += 1
})
const total = list.length || 1
winRecords.value = Object.values(aggregate).map(it => ({
...it,
percent: ((it.count / total) * 100).toFixed(1)
}))
} catch (e) {
console.error('fetchWinRecords error', e)
winRecords.value = []
}
}
function onPreviewBanner() {
const url = detail.value.banner || ''
if (url) uni.previewImage({ urls: [url], current: url })
}
function openPayment(count) {
const times = Math.max(1, Number(count || 1))
pendingCount.value = times
paymentAmount.value = (pricePerDrawYuan.value * times).toFixed(2)
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
paymentVisible.value = true
fetchPropCards()
fetchCoupons()
}
async function onPaymentConfirm(data) {
selectedCoupon.value = data && data.coupon ? data.coupon : null
selectedCard.value = data && data.card ? data.card : null
paymentVisible.value = false
await onMachineDraw(pendingCount.value)
}
async function fetchPropCards() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getItemCards(user_id)
let list = []
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
propCards.value = list.map((i, idx) => ({
id: i.id ?? i.card_id ?? String(idx),
name: i.name ?? i.title ?? '道具卡'
}))
} catch (e) {
propCards.value = []
}
}
async function fetchCoupons() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getUserCoupons(user_id, 0, 1, 100)
let list = []
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
coupons.value = list.map((i, idx) => {
const amountCents = (i.remaining !== undefined && i.remaining !== null) ? Number(i.remaining) : Number(i.amount ?? i.value ?? 0)
const amt = isNaN(amountCents) ? 0 : (amountCents / 100)
return {
id: i.id ?? i.coupon_id ?? String(idx),
name: i.name ?? i.title ?? '优惠券',
amount: Number(amt).toFixed(2)
}
})
} catch (e) {
coupons.value = []
}
}
function buildRewardLookup(list) {
const map = new Map()
const arr = Array.isArray(list) ? list : []
arr.forEach(it => {
const id = it && (it.id ?? it.reward_id ?? it.product_id)
const key = Number(id)
if (Number.isFinite(key)) map.set(key, it)
})
return map
}
function extractResultList(resultRes) {
const root = resultRes && (resultRes.data ?? resultRes.result ?? resultRes)
if (!root) return []
if (Array.isArray(root.results)) return root.results
if (Array.isArray(root.list)) return root.list
if (Array.isArray(root.items)) return root.items
if (Array.isArray(root.data)) return root.data
if (root.results && root.results.data && Array.isArray(root.results.data)) return root.results.data
return []
}
function mapResultsToFlipItems(resultRes, poolRewards) {
const list = extractResultList(resultRes)
const lookup = buildRewardLookup(poolRewards)
const poolArr = Array.isArray(poolRewards) ? poolRewards : []
return list.filter(Boolean).map(d => {
const rewardId = d.reward_id ?? d.rewardId ?? d.product_id ?? d.productId ?? d.id
const rewardName = String((d.reward_name ?? d.rewardName ?? d.product_name ?? d.productName ?? d.title ?? d.name) || '')
const key = Number(rewardId)
const fromId = Number.isFinite(key) ? lookup.get(key) : null
const fromName = !fromId && rewardName ? (poolArr.find(x => String(x && x.title || '') === rewardName) || poolArr.find(x => String(x && x.title || '').includes(rewardName))) : null
const it = fromId || fromName || null
const title = rewardName || String((it && it.title) || '奖励')
const image = String((it && it.image) || d.image || d.img || d.pic || d.product_image || '')
return { title, image }
})
}
async function onMachineDraw(count) {
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
const openid = uni.getStorageSync('openid')
if (!openid) { uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' }); return }
drawLoading.value = true
try {
const times = Math.max(1, Number(count || 1))
const joinRes = await joinLottery({
activity_id: Number(aid),
issue_id: Number(iid),
channel: 'miniapp',
count: times,
coupon_id: selectedCoupon.value && selectedCoupon.value.id ? Number(selectedCoupon.value.id) : 0,
item_card_id: selectedCard.value && selectedCard.value.id ? Number(selectedCard.value.id) : 0
})
const orderNo = joinRes && (joinRes.order_no || joinRes.data?.order_no || joinRes.result?.order_no)
if (!orderNo) throw new Error('未获取到订单号')
const payRes = await createWechatOrder({ openid, order_no: orderNo })
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
const resultRes = await getLotteryResult(orderNo)
const items = mapResultsToFlipItems(resultRes, currentIssueRewards.value)
showFlip.value = true
await nextTick()
try { if (flipRef.value && flipRef.value.reset) flipRef.value.reset() } catch (_) {}
setTimeout(() => {
if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults(items)
}, 100)
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
drawLoading.value = false
}
}
function onMachineTry() {
const list = rewardsMap.value[currentIssueId.value] || []
if (!list.length) { uni.showToast({ title: '暂无奖池', icon: 'none' }); return }
const idx = Math.floor(Math.random() * list.length)
const it = list[idx]
uni.showModal({ title: '试一试', content: it.title || '随机预览', showCancel: false, success: () => { if (it.image) uni.previewImage({ urls: [it.image], current: it.image }) } })
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
}
})
function closeFlip() { showFlip.value = false }
</script>
<style lang="scss" scoped>
/* 柯大鸭潮玩 - 无限赏活动页面 */
.page-wrapper {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
}
.orb-1 {
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.2), transparent 70%);
top: -200rpx;
left: -200rpx;
animation: float 10s ease-in-out infinite;
}
.orb-2 {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($accent-gold, 0.2), transparent 70%);
bottom: 20%;
right: -100rpx;
animation: float 12s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
.page-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 700rpx;
z-index: 1;
}
.bg-image {
width: 100%;
height: 100%;
filter: blur(30rpx) brightness(0.9);
transform: scale(1.1);
}
.bg-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba($bg-page, 0.2) 0%, $bg-page 90%, $bg-page 100%);
}
.main-scroll {
position: relative;
z-index: 2;
height: 100vh;
}
.header-card {
margin: $spacing-xl $spacing-lg;
background: rgba($bg-card, 0.85);
backdrop-filter: blur(24rpx);
border-radius: $radius-xl;
padding: $spacing-lg;
display: flex;
align-items: center;
box-shadow: $shadow-card;
border: 1rpx solid rgba(255, 255, 255, 0.6);
position: relative;
overflow: hidden;
}
.header-cover {
width: 180rpx;
height: 180rpx;
border-radius: $radius-md;
margin-right: $spacing-lg;
background: $bg-secondary;
box-shadow: $shadow-md;
flex-shrink: 0;
}
.header-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: 6rpx 0;
}
.header-title {
font-size: $font-xl;
font-weight: 800;
color: $text-main;
margin-bottom: $spacing-xs;
line-height: 1.3;
@include text-ellipsis(2);
}
.header-price-row {
display: flex;
align-items: baseline;
color: $brand-primary;
margin-bottom: $spacing-sm;
text-shadow: 0 2rpx 4rpx rgba($brand-primary, 0.1);
}
.price-symbol { font-size: $font-md; font-weight: 700; }
.price-num { font-size: $font-xxl; font-weight: 900; margin: 0 4rpx; font-family: 'DIN Alternate', sans-serif; }
.price-unit { font-size: $font-sm; color: $text-sub; margin-left: 4rpx; }
.header-tags {
display: flex;
gap: $spacing-xs;
flex-wrap: wrap;
}
.tag-item {
font-size: $font-xs;
color: $brand-primary-dark;
background: rgba($brand-primary, 0.08);
padding: 4rpx $spacing-sm;
border-radius: $radius-sm;
font-weight: 600;
border: 1rpx solid rgba($brand-primary, 0.1);
}
.header-actions {
display: flex;
flex-direction: column;
gap: 28rpx;
margin-left: 16rpx;
padding-left: 24rpx;
border-left: 2rpx solid #E8E8E8;
justify-content: center;
align-self: stretch;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
&:active {
opacity: 0.6;
}
}
.action-icon {
width: 44rpx;
height: 44rpx;
margin-bottom: 8rpx;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.rules-icon {
background-color: #999;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E");
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E");
mask-size: cover;
-webkit-mask-size: cover;
}
.cabinet-icon {
background-color: #999;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 3H4c-1.1 0-2 .9-2 2v16l4-4h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 11H7v-2h4v2zm6-4H7V8h10v2z'/%3E%3C/svg%3E");
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 3H4c-1.1 0-2 .9-2 2v16l4-4h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 11H7v-2h4v2zm6-4H7V8h10v2z'/%3E%3C/svg%3E");
mask-size: cover;
-webkit-mask-size: cover;
}
.action-label {
font-size: 22rpx;
color: #666;
letter-spacing: 1rpx;
}
.section-container {
margin: 0 $spacing-lg $spacing-lg;
background: rgba(255, 255, 255, 0.9);
border-radius: $radius-xl;
padding: $spacing-lg;
box-shadow: $shadow-sm;
backdrop-filter: blur(10rpx);
}
.modern-tabs {
display: flex;
background: $bg-secondary;
padding: 8rpx;
border-radius: $radius-lg;
margin-bottom: $spacing-lg;
}
.tab-item {
flex: 1;
text-align: center;
padding: $spacing-md 0;
font-size: $font-md;
color: $text-sub;
border-radius: $radius-md;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&.active {
background: #FFFFFF;
color: $brand-primary;
box-shadow: $shadow-sm;
}
}
.active-dot {
width: 8rpx; height: 8rpx;
background: $brand-primary;
border-radius: 50%;
position: absolute;
bottom: 8rpx; left: 50%; transform: translateX(-50%);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding: 0 4rpx;
}
.section-title {
font-size: $font-lg;
font-weight: 800;
color: $text-main;
position: relative;
padding-left: $spacing-lg;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8rpx;
height: 28rpx;
background: $gradient-brand;
border-radius: 4rpx;
}
}
.section-more {
font-size: $font-sm;
color: $text-tertiary;
display: flex;
align-items: center;
&::after {
content: '>';
font-family: monospace;
margin-left: 6rpx;
font-weight: 700;
}
}
.preview-scroll {
white-space: nowrap;
width: 100%;
}
.preview-item {
display: inline-block;
width: 180rpx;
margin-right: $spacing-md;
vertical-align: top;
position: relative;
transition: transform 0.2s;
&:active { transform: scale(0.96); }
&:last-child { margin-right: 0; }
}
.preview-img {
width: 180rpx;
height: 180rpx;
border-radius: $radius-lg;
background: $bg-secondary;
margin-bottom: $spacing-sm;
box-shadow: $shadow-sm;
border: 1rpx solid rgba(0,0,0,0.03);
}
.preview-name {
font-size: $font-xs;
color: $text-secondary;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
font-weight: 500;
}
.prize-tag {
position: absolute;
top: 8rpx;
left: 8rpx;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: $font-xxs;
padding: 2rpx $spacing-xs;
border-radius: 4rpx;
z-index: 10;
font-weight: 700;
backdrop-filter: blur(4px);
transform: scale(0.9);
transform-origin: top left;
}
.prize-tag.tag-boss {
background: $gradient-brand;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.4);
}
/* 新增:等级分组样式 */
.prize-level-row {
margin-bottom: $spacing-lg;
background: rgba(0,0,0,0.02);
padding: $spacing-md;
border-radius: $radius-lg;
&:last-child { margin-bottom: 0; }
}
.level-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
}
.level-badge {
display: inline-block;
font-size: $font-xs;
font-weight: 900;
color: $text-main;
background: #F0F0F0;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-style: italic;
border: 1rpx solid rgba(0,0,0,0.05);
box-shadow: $shadow-xs;
}
.level-prob {
font-size: 22rpx;
color: $brand-primary;
font-weight: 800;
}
.level-badge.badge-boss {
background: $gradient-brand;
color: #fff;
border: none;
}
.selector-container {
display: flex;
flex-direction: column;
background: rgba($bg-card, 0.95);
backdrop-filter: blur(20rpx);
}
.issue-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
background: $bg-grey;
border-radius: $radius-round;
padding: 10rpx;
border: 1rpx solid $border-color-light;
}
.issue-switch-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background: $bg-card;
border-radius: 50%;
box-shadow: $shadow-sm;
transition: all 0.2s;
color: $text-secondary;
&:active {
transform: scale(0.9);
background: $bg-secondary;
color: $brand-primary;
}
}
.arrow { font-size: $font-sm; font-weight: 800; }
.issue-info-center {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.issue-current-text {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
}
.issue-status-badge {
font-size: $font-xs;
color: $uni-color-success;
font-weight: 700;
}
.animate-enter {
animation: fadeInUp 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40rpx); }
to { opacity: 1; transform: translateY(0); }
}
.banner {
padding: $spacing-lg $spacing-lg 0;
animation: fadeInDown 0.6s $ease-out;
}
.banner-img {
width: 100%;
border-radius: $radius-lg;
box-shadow: $shadow-lg;
}
/* 商品信息卡片 */
.product-card {
margin: $spacing-lg;
background: $bg-glass;
backdrop-filter: blur(20rpx);
border-radius: $radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-card;
animation: fadeInUp 0.6s $ease-out 0.1s backwards;
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.product-info {
display: flex;
align-items: flex-start;
gap: $spacing-lg;
}
.product-thumb {
width: 140rpx;
height: 140rpx;
border-radius: $radius-md;
flex-shrink: 0;
background: $bg-page;
box-shadow: $shadow-inner;
}
.product-detail {
flex: 1;
min-width: 0;
}
.product-name {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
margin-bottom: $spacing-sm;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.product-price {
font-size: $font-xl;
font-weight: 800;
color: $brand-primary;
font-family: 'DIN Alternate', sans-serif;
}
.product-actions {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
/* 期号切换条 */
.issue-bar {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-lg;
margin: 0 $spacing-lg $spacing-lg;
padding: $spacing-md $spacing-lg;
background: $bg-glass;
backdrop-filter: blur(20rpx);
border-radius: $radius-round;
box-shadow: $shadow-sm;
animation: fadeInUp 0.6s $ease-out 0.2s backwards;
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.nav-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: $bg-page;
color: $text-sub;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-sm;
padding: 0;
margin: 0;
line-height: 1;
transition: all $transition-fast;
border: none;
&:active {
background: darken($bg-page, 5%);
transform: scale(0.9);
}
}
.issue-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
min-width: 200rpx;
}
.issue-label {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
}
/* 玩法福利标签 */
.gameplay-tags {
display: flex;
gap: $spacing-md;
padding: 0 $spacing-lg;
margin-bottom: $spacing-lg;
flex-wrap: wrap;
animation: fadeInUp 0.6s $ease-out 0.3s backwards;
}
.tag {
padding: $spacing-sm $spacing-lg;
border-radius: $radius-round;
font-size: $font-sm;
font-weight: 600;
display: flex;
align-items: center;
box-shadow: $shadow-sm;
backdrop-filter: blur(4px);
}
.tag-pool {
background: $color-success;
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba($color-success, 0.3);
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
.tag-drop {
background: $gradient-brand;
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.3);
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
.tag-free {
background: $gradient-gold;
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba($accent-gold, 0.3);
text-shadow: 0 1rpx 2rpx rgba(0,0,0,0.1);
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
/* 底部多档位抽赏按钮 - 高级重置 */
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 20rpx;
padding: 32rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(30rpx);
box-shadow: 0 -12rpx 40rpx rgba(0, 0, 0, 0.08);
z-index: 999;
border-top: 1rpx solid rgba(255, 255, 255, 0.8);
}
.tier-btn {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx 10rpx;
background: #FFF;
border: 2rpx solid rgba($brand-primary, 0.1);
border-radius: 28rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.03);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
margin: 0;
line-height: normal;
&:active {
transform: scale(0.92);
background: #F9F9F9;
box-shadow: none;
}
}
.tier-price {
font-size: 34rpx;
font-weight: 900;
color: $text-main;
font-family: 'DIN Alternate', sans-serif;
letter-spacing: -1rpx;
}
.tier-label {
font-size: 22rpx;
color: $brand-primary;
margin-top: 6rpx;
font-weight: 800;
font-style: italic;
}
/* 热门/最高档位 - 高级动效 */
.tier-hot {
background: $gradient-brand !important;
border: none !important;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.35) !important;
position: relative;
overflow: hidden;
.tier-price {
color: #FFF !important;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.tier-label {
color: rgba(255, 255, 255, 0.9) !important;
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.1);
}
/* 流光效果 */
&::before {
content: '';
position: absolute;
top: -50%;
left: -150%;
width: 200%;
height: 200%;
background: linear-gradient(
120deg,
rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.4) 50%,
rgba(255, 255, 255, 0) 70%
);
transform: rotate(25deg);
animation: hotSweep 4s infinite cubic-bezier(0.19, 1, 0.22, 1);
pointer-events: none;
z-index: 1;
}
}
@keyframes hotSweep {
0% { left: -150%; }
100% { left: 150%; }
}
.rewards-overlay { position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 9000; }
.rewards-mask {
position: absolute; left: 0; right: 0; top: 0; bottom: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(10rpx);
}
.rewards-panel {
position: absolute;
left: $spacing-lg;
right: $spacing-lg;
bottom: calc(env(safe-area-inset-bottom) + 24rpx);
max-height: 70vh;
background: rgba($bg-card, 0.95);
border-radius: $radius-xl;
box-shadow: $shadow-card;
border: 1rpx solid rgba(255,255,255,0.5);
overflow: hidden;
animation: fadeInUp 0.25s ease-out;
}
.rewards-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-lg;
border-bottom: 1rpx solid rgba(0,0,0,0.06);
}
.rewards-title {
font-size: $font-md;
font-weight: 800;
color: $text-main;
}
.rewards-close {
font-size: 48rpx;
line-height: 1;
color: $text-tertiary;
padding: 0 10rpx;
}
.rewards-list {
max-height: 60vh;
padding: $spacing-lg;
}
.rewards-group-v2 {
margin-bottom: $spacing-xl;
&:last-child { margin-bottom: 0; }
}
.group-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
padding: 0 4rpx;
}
.group-badge {
font-size: $font-xs;
font-weight: 900;
color: $text-main;
background: #F0F0F0;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-style: italic;
border: 1rpx solid rgba(0,0,0,0.05);
box-shadow: $shadow-xs;
&.badge-boss {
background: $gradient-gold;
color: #78350F;
border-color: rgba(217, 119, 6, 0.3);
}
}
.group-total-prob {
font-size: 24rpx;
color: $brand-primary;
font-weight: 800;
}
.rewards-item {
display: flex;
align-items: center;
padding: $spacing-md;
border-radius: $radius-lg;
background: rgba(255,255,255,0.75);
border: 1rpx solid rgba(0,0,0,0.03);
box-shadow: $shadow-xs;
margin-bottom: $spacing-md;
}
.rewards-thumb {
width: 96rpx;
height: 96rpx;
border-radius: $radius-md;
background: $bg-secondary;
flex-shrink: 0;
margin-right: $spacing-md;
}
.rewards-info { flex: 1; min-width: 0; }
.rewards-name-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
margin-bottom: 8rpx;
}
.rewards-name {
font-size: $font-md;
font-weight: 700;
color: $text-main;
@include text-ellipsis(1);
}
.rewards-tag {
font-size: $font-xs;
font-weight: 800;
color: #fff;
background: $gradient-brand;
border-radius: $radius-sm;
padding: 4rpx 10rpx;
flex-shrink: 0;
}
.rewards-percent {
font-size: $font-sm;
color: $text-sub;
font-weight: 600;
}
.rewards-empty {
padding: 60rpx 0 20rpx;
text-align: center;
color: $text-tertiary;
font-size: $font-sm;
}
/* 翻牌弹窗 */
.flip-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 10000;
animation: fadeIn 0.3s ease-out;
}
.flip-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
backdrop-filter: blur(10px);
z-index: 1;
}
.flip-content {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
padding: 24rpx;
width: 100%;
box-sizing: border-box;
z-index: 2;
justify-content: center;
align-items: stretch;
overflow-y: auto;
animation: zoomIn 0.3s $ease-bounce;
}
.overlay-close {
align-self: center;
margin-top: 60rpx;
width: 240rpx;
height: 88rpx;
line-height: 88rpx;
background: rgba(255,255,255,0.15) !important;
border: 1rpx solid rgba(255,255,255,0.3);
color: #FFFFFF !important;
border-radius: $radius-round;
font-weight: 600;
font-size: 30rpx;
backdrop-filter: blur(10px);
transition: all $transition-fast;
&:active {
background: rgba(255,255,255,0.25) !important;
transform: scale(0.95);
}
}
/* ============================================
Purchase Records Styles
============================================ */
.records-list {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.record-item {
display: flex;
background: #FFFFFF;
padding: $spacing-lg;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
align-items: center;
}
.record-img {
width: 100rpx; height: 100rpx;
border-radius: $radius-md;
background: $bg-secondary;
margin-right: $spacing-lg;
}
.record-info {
flex: 1;
}
.record-title {
font-size: $font-md;
font-weight: 600;
color: $text-main;
margin-bottom: $spacing-xs;
}
.record-meta {
display: flex;
gap: $spacing-md;
font-size: $font-sm;
color: $text-sub;
}
.record-count {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
padding: 2rpx $spacing-sm;
border-radius: $radius-sm;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl * 2;
color: $text-tertiary;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: $spacing-md;
opacity: 0.6;
}
.empty-text {
font-size: $font-md;
color: $text-sub;
}
</style>