657 lines
18 KiB
Vue
Raw Permalink 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>
<scroll-view class="page" scroll-y>
<view class="banner" v-if="detail.banner">
<image class="banner-img" :src="detail.banner" mode="widthFix" />
</view>
<view class="header">
<view class="title">{{ detail.name || detail.title || '-' }}</view>
<view class="meta" v-if="detail.price_draw !== undefined">参与价{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</view>
</view>
<view class="issues" v-if="showIssues">
<view class="issues-title">期数</view>
<view v-if="issues.length" class="issues-list">
<picker-view class="issue-picker" :value="[selectedIssueIndex]" @change="onIssueChange">
<picker-view-column>
<view class="picker-item" v-for="it in issues" :key="it.id">{{ it.title || ('' + (it.no || it.index || it.issue_no || '-') + '') }}</view>
</picker-view-column>
</picker-view>
<view class="tabs">
<view class="tab" :class="{ active: tabActive === 'pool' }" @click="tabActive = 'pool'">本机奖池</view>
<view class="tab" :class="{ active: tabActive === 'records' }" @click="tabActive = 'records'">中奖记录</view>
</view>
<view v-show="tabActive === 'pool'">
<view class="rewards-grid" v-if="currentIssueId && rewardsMap[currentIssueId] && rewardsMap[currentIssueId].length">
<view v-for="(rw, idx) in rewardsMap[currentIssueId]" :key="rw.id"
class="reward-card animate-stagger"
:style="{ '--delay': idx * 0.05 + 's' }">
<view class="card-header">
<text class="card-title">{{ rw.title }}</text>
<text v-if="rw.boss" class="badge-boss">BOSS</text>
</view>
<view class="image-wrapper">
<image v-if="rw.image" class="reward-image" :src="rw.image" mode="aspectFill" />
<text class="prob-tag absolute-tag">概率 {{ rw.percent }}%</text>
</view>
</view>
</view>
<view class="empty-state" v-else>
<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 animate-stagger"
:style="{ '--delay': idx * 0.05 + 's' }">
<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>
<text v-if="it.percent !== undefined">占比 {{ it.percent }}%</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 v-else class="issues-empty">暂无期数</view>
</view>
</scroll-view>
<view class="float-bar">
<button class="action-btn primary" @click="onParticipate">
立即参与
<view class="btn-shine"></view>
</button>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import ElCard from '../../../components/ElCard.vue'
import { onLoad } from '@dcloudio/uni-app'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, drawActivityIssue, getActivityWinRecords } from '../../../api/appUser'
const detail = ref({})
const statusText = 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 tabActive = ref('pool')
const winRecords = ref([])
function statusToText(s) {
if (s === 1) return '进行中'
if (s === 0) return '未开始'
if (s === 2) return '已结束'
return String(s || '')
}
async function fetchDetail(id) {
const data = await getActivityDetail(id)
detail.value = data || {}
statusText.value = statusToText(detail.value.status)
}
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 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 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 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)
}))
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 normalizeWinRecords(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? i.record_id ?? i.product_id ?? String(idx),
title: i.title ?? i.name ?? i.product_name ?? '',
image: cleanUrl(i.image ?? i.img ?? i.pic ?? i.product_image ?? ''),
count: Number(i.count ?? i.total ?? i.qty ?? 1) || 1,
percent: i.percent !== undefined ? Math.round(Number(i.percent) * 10) / 10 : undefined
}))
}
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)
}
async function fetchWinRecords(activityId) {
try {
const data = await getActivityWinRecords(activityId, 1, 50)
winRecords.value = normalizeWinRecords(data)
} catch (e) {
winRecords.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) {
const v = e && e.detail && e.detail.value
const idx = Array.isArray(v) ? (v[0] || 0) : 0
const arr = issues.value || []
const bounded = Math.min(Math.max(0, idx), arr.length - 1)
selectedIssueIndex.value = bounded
const cur = arr[bounded]
currentIssueId.value = (cur && cur.id) || ''
}
function onPreviewBanner() {
const url = detail.value.banner || ''
if (url) uni.previewImage({ urls: [url], current: url })
}
async function onParticipate() {
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
uni.showLoading({ title: '抽选中...' })
try {
const res = await drawActivityIssue(aid, iid)
uni.hideLoading()
const obj = res || {}
const data = obj.data || obj.result || obj.reward || obj.item || obj
const name = String((data && (data.title || data.name || data.product_name)) || '未知奖励')
const img = String((data && (data.image || data.img || data.pic || data.product_image)) || '')
uni.showModal({ title: '抽选结果', content: '恭喜获得:' + name, showCancel: false, success: () => { if (img) uni.previewImage({ urls: [img], current: img }) } })
} catch (err) {
uni.hideLoading()
const msg = String((err && (err.message || err.msg)) || '抽选失败')
uni.showToast({ title: msg, icon: 'none' })
}
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
fetchWinRecords(id)
}
ensureElCard()
})
</script>
<style lang="scss" scoped>
/* ============================================
对对碰活动页面 - 高级设计重构 (SCSS Integration)
============================================ */
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80rpx);
opacity: 0.6;
}
.orb-1 {
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.2) 0%, transparent 70%);
top: -100rpx; left: -100rpx;
}
.orb-2 {
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($accent-gold, 0.15) 0%, transparent 70%);
bottom: -100rpx; right: -100rpx;
}
.page-content {
flex: 1;
position: relative;
z-index: 1;
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
/* Banner */
.banner-wrapper {
margin: $spacing-md $spacing-lg;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-lg;
position: relative;
animation: fadeInDown 0.6s ease-out;
}
.banner-img {
width: 100%;
display: block;
}
.banner-shadow {
position: absolute;
bottom: 0; left: 0; width: 100%; height: 40%;
background: linear-gradient(to top, rgba(0,0,0,0.3), transparent);
}
/* Header */
.header-section {
padding: 0 $spacing-lg;
margin-bottom: $spacing-lg;
text-align: center;
animation: fadeIn 0.8s ease-out;
}
.title-row {
margin-bottom: $spacing-sm;
}
.title-text {
font-size: $font-xxl;
font-weight: 900;
background: $gradient-brand;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
.price-tag {
display: inline-flex;
align-items: baseline;
background: rgba($bg-card, 0.6);
padding: $spacing-xs $spacing-lg;
border-radius: $radius-round;
backdrop-filter: blur(20rpx);
box-shadow: $shadow-sm;
}
.price-label { font-size: $font-sm; color: $text-sub; margin-right: $spacing-xs; }
.price-symbol { font-size: $font-sm; color: $brand-primary; font-weight: 700; }
.price-value { font-size: $font-xl; color: $brand-primary; font-weight: 900; font-family: 'DIN Alternate', sans-serif; }
/* Glass Card */
.glass-card {
margin: 0 $spacing-lg $spacing-lg;
background: rgba($bg-card, 0.8);
backdrop-filter: blur(40rpx);
border-radius: $radius-xl;
padding: $spacing-lg;
box-shadow: $shadow-card;
border: 1rpx solid rgba(255, 255, 255, 0.6);
animation: fadeInUp 0.6s ease-out 0.2s backwards;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
padding: 0 4rpx;
}
.section-title {
font-size: $font-lg;
font-weight: 800;
color: $text-main;
position: relative;
padding-left: 20rpx;
&::before {
content: '';
position: absolute;
left: 0; top: 50%; transform: translateY(-50%);
width: 8rpx; height: 32rpx;
background: $gradient-brand;
border-radius: 4rpx;
}
}
.issue-indicator {
font-size: $font-sm;
color: $brand-primary;
background: rgba($brand-primary, 0.1);
padding: 4rpx $spacing-md;
border-radius: $radius-round;
font-weight: 600;
}
/* Custom Picker */
.custom-picker {
height: 280rpx;
background: rgba($bg-secondary, 0.5);
border-radius: $radius-lg;
margin-bottom: $spacing-lg;
overflow: hidden;
}
.picker-item {
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-md;
}
.picker-text { font-size: $font-lg; color: $text-main; font-weight: 600; }
.picker-status {
font-size: $font-xs; color: $text-sub; background: rgba(0,0,0,0.05); padding: 2rpx $spacing-sm; border-radius: $radius-sm;
&.status-active { background: #D1FAE5; color: #059669; }
}
/* Modern Tabs */
.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%);
}
/* Rewards Grid */
.rewards-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-lg;
}
.reward-card {
background: #FFFFFF;
border-radius: $radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-sm;
border: 1rpx solid rgba(0,0,0,0.03);
display: flex;
flex-direction: column;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-md;
height: 44rpx;
}
.card-title {
font-size: $font-md;
color: $text-main;
font-weight: 600;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 8rpx;
}
.badge-boss {
font-size: $font-xs;
background: $gradient-gold;
color: #78350F;
padding: 2rpx $spacing-sm;
border-radius: $radius-sm;
font-weight: 800;
flex-shrink: 0;
}
.card-body {
flex: 1;
display: flex;
flex-direction: column;
}
.image-wrapper {
width: 100%;
padding-bottom: 100%;
position: relative;
background: $bg-secondary;
border-radius: $radius-md;
overflow: hidden;
margin-bottom: $spacing-sm;
}
.reward-image {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
}
.prob-tag {
position: absolute;
top: 8rpx; left: 8rpx;
font-size: $font-xs;
color: #fff;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4rpx);
padding: 4rpx $spacing-sm;
border-radius: $radius-sm;
z-index: 2;
}
/* Records List */
.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: 80rpx 0;
color: $text-placeholder;
}
.empty-icon { font-size: 80rpx; margin-bottom: $spacing-lg; opacity: 0.5; }
.empty-text { font-size: $font-md; }
/* Float Bar */
.float-bar {
position: fixed;
left: 0; right: 0; bottom: 0;
padding: $spacing-lg $spacing-xl;
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
box-shadow: 0 -8rpx 30rpx rgba(0, 0, 0, 0.05);
z-index: 100;
animation: slideUp 0.4s ease-out backwards;
}
.action-btn {
height: 96rpx;
border-radius: $radius-round;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-xl;
font-weight: 800;
position: relative;
overflow: hidden;
transition: all 0.2s;
&.primary {
background: $gradient-brand;
color: #fff;
box-shadow: $shadow-warm;
}
&:active { transform: scale(0.98); }
}
.btn-shine {
position: absolute;
top: 0; left: -100%; width: 50%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
transform: skewX(-20deg);
animation: shine 3s infinite;
}
/* Animation Utilities */
.animate-stagger {
animation: fadeInUp 0.5s ease-out backwards;
animation-delay: var(--delay, 0s);
}
</style>