631 lines
17 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="page-bg">
<image class="bg-image" :src="detail.banner" mode="aspectFill" />
<view class="bg-mask"></view>
</view>
<!-- 导航栏占位如果有自定义导航栏需求 -->
<!-- <view class="nav-bar-placeholder"></view> -->
<!-- 主要内容区域 -->
<scroll-view class="main-scroll" scroll-y>
<!-- 头部信息卡片 -->
<view class="header-card">
<image class="header-cover" :src="detail.banner" 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">
<text class="icon">📋</text>
<text>规则</text>
</view>
<view class="action-btn" @tap="goCabinet">
<text class="icon">📦</text>
<text>盒柜</text>
</view>
</view>
</view>
<!-- 赏品概览 -->
<view class="section-container" v-if="currentIssueRewards.length > 0">
<view class="section-header">
<text class="section-title">赏品一览</text>
<text class="section-more">查看全部 ></text>
</view>
<scroll-view class="preview-scroll" scroll-x>
<view class="preview-item" v-for="(item, idx) in currentIssueRewards" :key="idx">
<view class="prize-tag" :class="{ 'tag-boss': item.boss }">{{ item.boss ? 'BOSS' : (item.grade || '赏') }}</view>
<image class="preview-img" :src="item.image" mode="aspectFill" />
<view class="preview-name">{{ item.title }}</view>
</view>
</scroll-view>
</view>
<!-- 选号区域 -->
<view class="section-container selector-container">
<!-- 期号切换 -->
<view class="issue-header">
<view class="issue-switch-btn" @click="prevIssue">
<text class="arrow"></text>
</view>
<view class="issue-info-center">
<text class="issue-current-text">{{ currentIssueTitle }}</text>
<text class="issue-status-badge">进行中</text>
</view>
<view class="issue-switch-btn" @click="nextIssue">
<text class="arrow"></text>
</view>
</view>
<!-- 选号组件 -->
<view class="selector-body" v-if="activityId && currentIssueId">
<YifanSelector
:activity-id="activityId"
:issue-id="currentIssueId"
:price-per-draw="Number(detail.price_draw || 0) / 100"
@payment-success="onPaymentSuccess"
/>
</view>
</view>
<!-- 底部垫高 -->
<view style="height: 180rpx;"></view>
</scroll-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>
</template>
<script setup>
import { ref, computed, getCurrentInstance } from 'vue'
import ElCard from '../../../components/ElCard.vue'
import { onLoad } from '@dcloudio/uni-app'
import FlipGrid from '../../../components/FlipGrid.vue'
import YifanSelector from '@/components/YifanSelector.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getActivityWinRecords } from '../../../api/appUser'
const detail = 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([])
const drawLoading = ref(false)
const points = ref(0)
const flipRef = ref(null)
const showFlip = ref(false)
const currentIssueRewards = computed(() => (currentIssueId.value && rewardsMap.value[currentIssueId.value]) ? rewardsMap.value[currentIssueId.value] : [])
const currentIssueTitle = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
const t = (cur && (cur.title || ('第' + (cur.no || '-') + '期'))) || '-'
return t
})
// 当前期剩余数量
const currentIssueRemain = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
return cur && cur.remain !== undefined ? cur.remain : ''
})
// 显示规则
function showRules() {
uni.showModal({
title: '活动规则',
content: detail.value.rules || '1. 选择号码进行抽选\n2. 每个号码对应一个奖品\n3. 已售号码不可再选',
showCancel: false
})
}
// 跳转盒柜
function goCabinet() {
uni.navigateTo({ url: '/pages/cabinet/index' })
}
function statusToText(s) {
if (s === 1) return '进行中'
if (s === 0) return '未开始'
if (s === 2) return '已结束'
return String(s || '')
}
const statusText = ref('')
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 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)
}))
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) {
// 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 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 onPaymentSuccess(payload) {
console.log('Payment Success:', payload)
const result = payload.result
let wonItems = []
// 尝试解析返回结果中的奖励列表
if (Array.isArray(result)) {
wonItems = result
} else if (result && Array.isArray(result.list)) {
wonItems = result.list
} else if (result && Array.isArray(result.data)) {
wonItems = result.data
} else if (result && Array.isArray(result.rewards)) {
wonItems = result.rewards
} else {
// 兜底:如果是单对象或无法识别,尝试作为单个物品处理
wonItems = result ? [result] : []
}
const items = wonItems.map(data => {
const title = String((data && (data.title || data.name || data.product_name || data.reward_name)) || '未知奖励')
const image = String((data && (data.image || data.img || data.pic || data.product_image || data.reward_image)) || '')
return { title, image }
})
showFlip.value = true
try { if (flipRef.value && flipRef.value.reset) flipRef.value.reset() } catch (_) {}
setTimeout(() => {
if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults(items)
}, 100)
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
fetchWinRecords(id)
}
ensureElCard()
})
function closeFlip() { showFlip.value = false }
</script>
<style scoped>
/* ============================================
一番赏页面 - 高级设计重构
============================================ */
.page-wrapper {
min-height: 100vh;
background: #F2F3F7;
position: relative;
overflow: hidden;
}
/* 顶部背景 */
.page-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 600rpx;
z-index: 1;
}
.bg-image {
width: 100%;
height: 100%;
filter: blur(40rpx);
opacity: 0.8;
}
.bg-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(242,243,247,0.3) 0%, #F2F3F7 100%);
}
.main-scroll {
position: relative;
z-index: 2;
height: 100vh;
}
/* 头部卡片 */
.header-card {
margin: 30rpx 24rpx;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20rpx);
border-radius: 32rpx;
padding: 24rpx;
display: flex;
align-items: center;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.08);
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.header-cover {
width: 160rpx;
height: 160rpx;
border-radius: 20rpx;
margin-right: 24rpx;
background: #EEE;
box-shadow: 0 8rpx 16rpx rgba(0,0,0,0.1);
}
.header-info {
flex: 1;
}
.header-title {
font-size: 34rpx;
font-weight: 700;
color: #1A1A1A;
margin-bottom: 12rpx;
line-height: 1.3;
}
.header-price-row {
display: flex;
align-items: baseline;
color: #FF6B35;
margin-bottom: 12rpx;
}
.price-symbol { font-size: 24rpx; font-weight: 600; }
.price-num { font-size: 40rpx; font-weight: 800; margin: 0 4rpx; }
.price-unit { font-size: 24rpx; color: #999; }
.header-tags {
display: flex;
gap: 12rpx;
}
.tag-item {
font-size: 20rpx;
color: #B45309;
background: #FFF4E6;
padding: 4rpx 12rpx;
border-radius: 6rpx;
}
.header-actions {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-left: 20rpx;
padding-left: 20rpx;
border-left: 1rpx solid #EEE;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
font-size: 20rpx;
color: #666;
}
.action-btn .icon {
font-size: 32rpx;
margin-bottom: 4rpx;
}
/* 通用板块容器 */
.section-container {
margin: 24rpx;
background: #FFFFFF;
border-radius: 32rpx;
padding: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
}
/* 板块标题 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding: 0 8rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1A1A1A;
}
.section-more {
font-size: 24rpx;
color: #999;
}
/* 奖品概览 */
.preview-scroll {
white-space: nowrap;
}
.preview-item {
display: inline-block;
width: 180rpx;
margin-right: 20rpx;
vertical-align: top;
}
.preview-img {
width: 180rpx;
height: 180rpx;
border-radius: 20rpx;
background: #F8F8F8;
margin-bottom: 12rpx;
box-shadow: inset 0 0 0 1rpx rgba(0,0,0,0.03);
}
.preview-name {
font-size: 24rpx;
color: #333;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.prize-tag {
position: absolute;
top: 8rpx;
left: 8rpx;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 18rpx;
padding: 2rpx 8rpx;
border-radius: 6rpx;
z-index: 10;
}
.prize-tag.tag-boss {
background: linear-gradient(135deg, #FF9F43, #FF6B35);
}
/* 选号区容器 */
.selector-container {
min-height: 600rpx;
display: flex;
flex-direction: column;
}
/* 期号头部 */
.issue-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
background: #F9FAFB;
border-radius: 20rpx;
padding: 12rpx;
}
.issue-switch-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: #FFFFFF;
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.issue-switch-btn:active {
transform: scale(0.95);
background: #F0F0F0;
}
.arrow {
font-size: 24rpx;
color: #999;
}
.issue-info-center {
display: flex;
flex-direction: column;
align-items: center;
}
.issue-current-text {
font-size: 30rpx;
font-weight: 700;
color: #333;
}
.issue-status-badge {
font-size: 20rpx;
color: #10B981;
background: #D1FAE5;
padding: 2rpx 12rpx;
border-radius: 999rpx;
margin-top: 4rpx;
}
.selector-body {
flex: 1;
}
/* 翻牌弹窗 */
.flip-overlay { position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 10000; }
.flip-mask { position: absolute; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 1; }
.flip-content { position: relative; display: flex; flex-direction: column; height: 100%; padding: 24rpx; z-index: 2; }
.overlay-close { background: linear-gradient(135deg, #FFD93D, #FFB800) !important; color: #6b4b1f !important; border-radius: 999rpx; align-self: flex-end; font-weight: 600; box-shadow: 0 6rpx 16rpx rgba(255, 184, 0, 0.35); }
</style>