2025-12-15 11:02:37 +08:00

332 lines
13 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>
<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" v-if="currentIssueId && rewardsMap[currentIssueId] && rewardsMap[currentIssueId].length">
<el-card v-for="rw in rewardsMap[currentIssueId]" :key="rw.id" class="el-reward-card" shadow="hover">
<template #header>
<view class="el-card-header">
<text class="el-card-title">{{ rw.title }}</text>
<text v-if="rw.boss" class="badge-boss">BOSS</text>
</view>
</template>
<view class="card-image-wrap">
<image v-if="rw.image" class="card-image" :src="rw.image" mode="widthFix" />
<text class="prob-corner tl">概率 {{ rw.percent }}%</text>
</view>
</el-card>
</view>
<view class="rewards-empty" v-else>暂无奖励配置</view>
</view>
<view v-show="tabActive === 'records'">
<view class="records" v-if="winRecords.length">
<el-card v-for="it in winRecords" :key="it.id" class="el-reward-card" shadow="hover">
<template #header>
<view class="el-card-header">
<text class="el-card-title">{{ it.title }}</text>
<text v-if="it.count !== undefined" class="badge-count">x{{ it.count }}</text>
</view>
</template>
<view class="card-image-wrap">
<image v-if="it.image" class="card-image" :src="it.image" mode="widthFix" />
<text v-if="it.percent !== undefined" class="prob-corner tl">占比 {{ it.percent }}%</text>
</view>
</el-card>
</view>
<view class="rewards-empty" v-else>暂无中奖记录</view>
</view>
</view>
<view v-else class="issues-empty">暂无期数</view>
</view>
</scroll-view>
<view class="float-actions">
<button class="float-btn primary" @click="onParticipate">立即参与</button>
</view>
</template>
<script setup>
import { ref, computed, getCurrentInstance } 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) || ''
}
async function ensureElCard() {
const inst = getCurrentInstance()
const app = inst && inst.appContext && inst.appContext.app
let comp = null
if (typeof window !== 'undefined' && window.ElementPlus && window.ElementPlus.ElCard) {
comp = window.ElementPlus.ElCard
} else {
comp = ElCard
}
if (app && !app.component('el-card')) app.component('el-card', comp)
}
function onPreviewBanner() {
const url = detail.value.banner || ''
if (url) uni.previewImage({ urls: [url], current: url })
}
function onParticipate() {
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
uni.showLoading({ title: '抽选中...' })
drawActivityIssue(aid, iid).then(res => {
try { uni.hideLoading() } catch (_) {}
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 => {
try { uni.hideLoading() } catch (_) {}
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 scoped>
.page { height: 100vh; padding-bottom: 140rpx }
.banner { padding: 24rpx }
.banner-img { width: 100% }
.header { padding: 0 24rpx }
.title { font-size: 36rpx; font-weight: 700; color: #DD2C00; text-align: center }
.meta { margin-top: 8rpx; font-size: 26rpx; color: #666 }
.actions { display: flex; padding: 24rpx; gap: 16rpx }
.btn { flex: 1 }
.primary { background-color: #007AFF; color: #fff }
.float-actions { position: fixed; left: 0; right: 0; bottom: 0; padding: 16rpx 24rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); background: rgba(255,255,255,0.9); box-shadow: 0 -6rpx 16rpx rgba(0,0,0,0.06); z-index: 9999 }
.float-btn { width: 100%; border-radius: 999rpx }
.issues { background: #fff; border-radius: 12rpx; margin: 0 24rpx 24rpx; padding: 16rpx }
.issues-title { font-size: 30rpx; font-weight: 600; margin-bottom: 12rpx }
.issues-list { }
.issue-picker { height: 200rpx; background: #f8f8f8; border-radius: 12rpx; margin-bottom: 64rpx }
.picker-item { height: 40rpx; line-height: 40rpx; text-align: center; font-size: 26rpx }
.tabs { display: flex; padding: 0 12rpx; margin-bottom: 16rpx }
.tab { flex: 1; text-align: center; font-size: 28rpx; padding: 16rpx 0; border: 2rpx solid #f0c58a; color: #8a5a2b; background: #fff3df; border-radius: 16rpx }
.tab + .tab { margin-left: 12rpx }
.tab.active { background: #ffdfaa; border-color: #ffb74d; color: #6b4b1f; font-weight: 600 }
.rewards { width: 100%; margin-top: 24rpx }
.reward-card { background: #fff; border-radius: 12rpx; overflow: hidden; box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06); margin-bottom: 12rpx }
.el-reward-card { margin-bottom: 12rpx }
.el-card-header { display: flex; align-items: center; justify-content: space-between }
.el-card-title { font-size: 28rpx; color: #222; flex: 1; margin-right: 8rpx; word-break: break-all }
.card-image-wrap { position: relative; padding-bottom: 48rpx }
.card-image { width: 100%; height: auto; display: block; background: #f0f4ff; position: relative; z-index: 1 }
.prob-corner { position: absolute; background: rgba(221,82,77,0.9); color: #fff; font-size: 22rpx; padding: 6rpx 12rpx; border-radius: 999rpx; z-index: 2 }
.prob-corner.tl { top: 12rpx; left: 12rpx }
.card-body { display: flex; align-items: center; justify-content: space-between; padding: 12rpx }
.card-title { font-size: 28rpx; color: #222; flex: 1; margin-right: 8rpx; word-break: break-all }
.badge-boss { background: #ff9f0a; color: #222; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 999rpx }
.badge-count { background: #ffd166; color: #6b4b1f; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 999rpx }
.rewards-empty { font-size: 24rpx; color: #999 }
.issues-empty { font-size: 24rpx; color: #999 }
</style>