1870 lines
52 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>
<ActivityPageLayout :cover-url="coverUrl">
<template #header>
<ActivityHeader
:title="detail.name || detail.title || '对对碰'"
:price="detail.price_draw"
price-unit="/"
:cover-url="coverUrl"
:tags="['随机玩法', '理性消费']"
@show-rules="showRules"
@go-cabinet="goCabinet"
/>
</template>
<view class="section-container animate-enter stagger-1">
<view class="section-header">
<text class="section-title">卡牌类型</text>
</view>
<view v-if="cardTypesLoading" class="card-types-loading">加载中...</view>
<view v-else-if="cardTypes.length === 0" class="card-types-empty">暂无可选卡牌类型</view>
<scroll-view v-else class="card-types-scroll" scroll-x="true">
<view class="card-types-row">
<view
v-for="it in cardTypes"
:key="it.code"
class="card-type-item"
:class="{ active: selectedCardTypeCode === it.code }"
@tap="() => selectCardType(it)"
>
<image v-if="it.image_url" class="card-type-img" :src="it.image_url" mode="aspectFill" />
<view class="card-type-name">{{ it.name }}</view>
<view v-if="it.quantity !== undefined && it.quantity !== null" class="card-type-qty">×{{ it.quantity }}</view>
</view>
</view>
</scroll-view>
</view>
<!-- 奖池/记录切换 -->
<ActivityTabs
v-model:current="tabActive"
:tabs="[{key: 'pool', label: '本机奖池'}, {key: 'records', label: '购买记录'}]"
/>
<!-- 奖池预览 -->
<RewardsPreview
v-if="tabActive === 'pool'"
:reward-groups="rewardGroups"
@view-all="openRewardsPopup"
/>
<!-- 购买记录 -->
<RecordsList
v-if="tabActive === 'records'"
:records="winRecords"
/>
<view style="height: 180rpx;"></view>
</scroll-view>
<template #footer>
<view class="float-bar">
<view class="float-bar-inner">
<view class="float-price">
<text class="currency">¥</text>
<text class="amount">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text>
<text class="unit">/次</text>
</view>
<view v-if="hasResumeGame" class="action-btn secondary" @tap="onResumeGame">
继续游戏
</view>
<view v-if="!hasResumeGame" class="action-btn primary" @tap="onParticipate">
立即参与
<view class="btn-shine"></view>
</view>
</view>
</view>
</template>
<template #modals>
<!-- 游戏弹窗 -->
<view v-if="gameVisible" class="rewards-overlay" @touchmove.stop.prevent>
<view class="rewards-mask" @tap="closeGame"></view>
<view class="rewards-panel" @tap.stop>
<view class="rewards-header">
<text class="rewards-title">对对碰游戏</text>
<text class="rewards-close" @tap="closeGame">×</text>
</view>
<scroll-view scroll-y class="game-scroll-list">
<view v-if="gameLoading" class="game-empty-state">加载中...</view>
<view v-else-if="gameError" class="game-empty-state">{{ gameError }}</view>
<view v-else>
<view class="game-info-card">
<view class="game-info-content">
<view class="game-info-title">总对数:{{ totalPairs }} 摸牌机会:{{ chance }}</view>
<view class="game-info-meta">
<text>牌组剩余 {{ deckRemaining }}</text>
<text v-if="selectedPositionText">位置 {{ selectedPositionText }}</text>
<text v-if="gameIdText">ID {{ gameIdText }}</text>
</view>
</view>
</view>
<view class="match-grid">
<view
v-for="(cell, idx) in handGridCells"
:key="idx"
class="match-cell"
:class="{ empty: cell.empty, chosen: cell.isChosen, picked: cell.isPicked }"
@tap="() => onCellTap(cell)"
>
<image v-if="cell.image" class="match-cell-img" :src="cell.image" mode="aspectFill" />
<view v-else class="match-cell-img"></view>
<text v-if="!cell.empty && cell.type" class="match-cell-type">{{ cell.type }}</text>
</view>
</view>
</view>
</scroll-view>
<view class="flip-actions" style="padding: 20rpx 24rpx;">
<button class="close-btn" style="flex: 1;" @tap="manualDraw" :disabled="gameLoading || !canManualDraw">摸牌</button>
<button class="close-btn" style="flex: 1; background: linear-gradient(135deg, #ff7a18, #ffb347); color: #fff;" @tap="advanceOne" :disabled="gameLoading">下一步</button>
</view>
</view>
</view>
<!-- 奖品详情弹窗 -->
<RewardsPopup
v-model:visible="rewardsVisible"
:title="`${detail.name || '奖池'} · 奖品与概率`"
:reward-groups="rewardGroups"
/>
<PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="coupons"
:propCards="propCards"
:showCards="true"
@confirm="onPaymentConfirm"
/>
</template>
</ActivityPageLayout>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PaymentPopup from '../../../components/PaymentPopup.vue'
import ActivityPageLayout from '@/components/activity/ActivityPageLayout.vue'
import ActivityHeader from '@/components/activity/ActivityHeader.vue'
import ActivityTabs from '@/components/activity/ActivityTabs.vue'
import RewardsPreview from '@/components/activity/RewardsPreview.vue'
import RewardsPopup from '@/components/activity/RewardsPopup.vue'
import RecordsList from '@/components/activity/RecordsList.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame, getIssueDrawLogs } 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([])
const paymentVisible = ref(false)
const coupons = ref([])
const propCards = ref([])
const selectedCoupon = ref(null)
const selectedCard = ref(null)
const paymentAmount = computed(() => (((Number(detail.value.price_draw || 0) / 100) || 0)).toFixed(2))
const cardTypesLoading = ref(false)
const cardTypes = ref([])
const selectedCardTypeCode = ref('')
const rewardsVisible = ref(false)
const resumeGame = ref(null)
const resumeIssueId = ref('')
const hasResumeGame = computed(() => {
const v = resumeGame.value || null
return !!(v && v.game_id)
})
const gameVisible = ref(false)
const gameLoading = ref(false)
const gameError = ref('')
const gameEntry = ref(null)
const gameIssueId = ref('')
const hand = ref([])
const deckIndex = ref(0)
const chance = ref(0)
const totalPairs = ref(0)
const gameFinished = ref(false)
const pickedHandIndex = ref(-1)
const gameIdText = computed(() => String((gameEntry.value && gameEntry.value.game_id) || ''))
const deckRemaining = computed(() => {
const entry = gameEntry.value || null
const deck = entry && Array.isArray(entry.all_cards) ? entry.all_cards : []
return Math.max(0, deck.length - Number(deckIndex.value || 0))
})
const cardTypeImageMap = computed(() => {
const map = {}
;(cardTypes.value || []).forEach(it => {
if (!it) return
const code = String(it.code || '')
if (!code) return
map[code] = cleanUrl(it.image_url || it.image || it.img || it.pic || '')
})
return map
})
const cardTypeNameMap = computed(() => {
const map = {}
;(cardTypes.value || []).forEach(it => {
if (!it) return
const code = String(it.code || '')
if (!code) return
map[code] = String(it.name || it.title || it.label || code)
})
return map
})
const selectedPositionCode = computed(() => String((gameEntry.value && gameEntry.value.position) || selectedCardTypeCode.value || ''))
const selectedPositionText = computed(() => {
const code = String(selectedPositionCode.value || '')
if (!code) return ''
const name = (cardTypeNameMap.value || {})[code]
return name ? `${name}(${code})` : code
})
const canManualDraw = computed(() => {
const entry = gameEntry.value || null
if (!entry || !entry.game_id) return false
if (Number(chance.value || 0) <= 0) return false
return canDrawOne()
})
function getCardTypeCode(card) {
const c = card || {}
const v = c.type ?? c.type_code ?? c.card_type_code ?? c.position ?? c.pos ?? c.code
return String(v || '')
}
const handGridCells = computed(() => {
const cards = Array.isArray(hand.value) ? hand.value : []
const imgMap = cardTypeImageMap.value || {}
const chosen = String(selectedPositionCode.value || '')
const picked = Number(pickedHandIndex.value || -1)
const cells = []
for (let i = 0; i < 18; i++) {
const raw = cards[i]
const code = raw ? getCardTypeCode(raw) : ''
const image = code ? (imgMap[code] || cleanUrl(raw.image || raw.image_url || raw.img || raw.pic || '')) : ''
cells.push({
id: raw && (raw.id ?? raw.card_id) ? (raw.id ?? raw.card_id) : String(i),
handIndex: raw ? i : -1,
type: code,
image,
empty: !raw,
isChosen: !!(code && chosen && code === chosen),
isPicked: !!(raw && i === picked)
})
}
return cells
})
const coverUrl = computed(() => cleanUrl(detail.value.banner || detail.value.cover || detail.value.image || ''))
const currentIssueRewards = computed(() => {
const iid = currentIssueId.value || ''
const m = rewardsMap.value || {}
return (iid && Array.isArray(m[iid])) ? m[iid] : []
})
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
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 currentIssueTitle = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
// 对对碰不显示“期数”,优先显示标题,兜底显示“奖池”
return (cur && (cur.title || '奖池')) || '-'
})
const currentIssueStatusText = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
return (cur && (cur.status_text || '')) || ''
})
const selectedCardType = computed(() => {
const code = String(selectedCardTypeCode.value || '')
if (!code) return null
return (cardTypes.value || []).find(i => i && i.code === code) || null
})
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 normalizeCardTypes(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
code: String(i.code ?? i.type_code ?? i.card_type_code ?? i.id ?? String(idx)),
name: i.name ?? i.title ?? i.label ?? '卡牌类型',
image_url: cleanUrl(i.image_url ?? i.image ?? i.img ?? i.pic ?? ''),
quantity: i.quantity !== undefined ? Number(i.quantity) : undefined
}))
}
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()
}
const MATCHING_GAME_CACHE_KEY = 'matching_game_cache_v1'
function getMatchingGameCache() {
const obj = uni.getStorageSync(MATCHING_GAME_CACHE_KEY) || {}
return typeof obj === 'object' && obj ? obj : {}
}
function findLatestMatchingGameCacheEntry(aid) {
const activityKey = String(aid || '')
if (!activityKey) return null
const cache = getMatchingGameCache()
const act = cache[activityKey]
if (!act || typeof act !== 'object') return null
let bestIssueId = ''
let bestEntry = null
let bestTs = -Infinity
Object.keys(act).forEach(issueId => {
const entry = act[issueId]
if (!entry || typeof entry !== 'object' || !entry.game_id) return
const ts = Number(entry.ts || 0)
if (!bestEntry || ts > bestTs) {
bestTs = ts
bestIssueId = issueId
bestEntry = entry
}
})
if (!bestEntry) return null
return { issue_id: bestIssueId, entry: bestEntry }
}
function syncResumeGame(aid) {
const latest = findLatestMatchingGameCacheEntry(aid)
if (!latest || !latest.entry || !latest.entry.game_id) {
resumeIssueId.value = ''
resumeGame.value = null
return null
}
resumeIssueId.value = String(latest.issue_id || '')
resumeGame.value = latest.entry
return latest
}
function writeMatchingGameCacheEntry(aid, iid, entry) {
const activityKey = String(aid || '')
const issueKey = String(iid || '')
if (!activityKey || !issueKey) return
const cache = getMatchingGameCache()
const act = (cache[activityKey] && typeof cache[activityKey] === 'object') ? cache[activityKey] : {}
act[issueKey] = entry
cache[activityKey] = act
uni.setStorageSync(MATCHING_GAME_CACHE_KEY, cache)
syncResumeGame(activityKey)
}
function readMatchingGameCacheEntry(aid, iid) {
const activityKey = String(aid || '')
const issueKey = String(iid || '')
if (!activityKey || !issueKey) return null
const cache = getMatchingGameCache()
const act = cache[activityKey] || {}
const entry = act && act[issueKey]
const ok = entry && typeof entry === 'object' && entry.game_id
return ok ? entry : null
}
function clearMatchingGameCacheEntry(aid, iid) {
const activityKey = String(aid || '')
const issueKey = String(iid || '')
const cache = getMatchingGameCache()
const act = cache[activityKey]
if (!act || typeof act !== 'object') {
syncResumeGame(activityKey)
return
}
if (act[issueKey] !== undefined) delete act[issueKey]
if (Object.keys(act).length === 0) delete cache[activityKey]
else cache[activityKey] = act
uni.setStorageSync(MATCHING_GAME_CACHE_KEY, cache)
syncResumeGame(activityKey)
}
function normalizeAllCards(v) {
const arr = Array.isArray(v) ? v : (v ? [v] : [])
return arr.map((it, idx) => {
const obj = (it && typeof it === 'object') ? { ...it } : { value: it }
if (obj.image_url !== undefined) obj.image_url = cleanUrl(obj.image_url)
if (obj.image !== undefined) obj.image = cleanUrl(obj.image)
if (!obj.id && obj.card_id) obj.id = obj.card_id
if (!obj.id) obj.id = String(idx)
return obj
})
}
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),
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) || ''
syncResumeGame(activityId.value)
}
function prevIssue() {
const arr = issues.value || []
if (!arr.length) return
const next = Math.max(0, Number(selectedIssueIndex.value || 0) - 1)
selectedIssueIndex.value = next
currentIssueId.value = (arr[next] && arr[next].id) || ''
syncResumeGame(activityId.value)
}
function nextIssue() {
const arr = issues.value || []
if (!arr.length) return
const next = Math.min(arr.length - 1, Number(selectedIssueIndex.value || 0) + 1)
selectedIssueIndex.value = next
currentIssueId.value = (arr[next] && arr[next].id) || ''
syncResumeGame(activityId.value)
}
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 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
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' })
}
function onPreviewBanner() {
const url = detail.value.banner || ''
if (url) uni.previewImage({ urls: [url], current: url })
}
function previewCard(c) {
const img = String(c && c.image || '')
if (img) uni.previewImage({ urls: [img], current: img })
}
async function openGame(latest) {
const aid = activityId.value || ''
const targetIssueId = String(latest && latest.issue_id || '')
if (targetIssueId && targetIssueId !== String(currentIssueId.value || '')) {
const inList = (issues.value || []).some(x => x && String(x.id) === targetIssueId)
if (inList) setSelectedById(targetIssueId)
}
gameIssueId.value = targetIssueId || String(currentIssueId.value || '')
gameEntry.value = latest && latest.entry ? latest.entry : null
gameVisible.value = true
gameError.value = ''
await applyResumeEntry(gameEntry.value)
restoreOrInitLocalGame()
await autoDrawIfStuck()
}
function closeGame() {
gameVisible.value = false
gameLoading.value = false
gameError.value = ''
gameEntry.value = null
gameIssueId.value = ''
pickedHandIndex.value = -1
}
function getSelectedCodeFromEntry(entry) {
const e = entry || {}
return String(e.position || selectedCardTypeCode.value || '')
}
function isSelectedCard(card, selectedCode) {
const code = String(selectedCode || '')
if (!code) return false
const c = card || {}
const v = c.position ?? c.pos ?? c.type_code ?? c.card_type_code ?? c.type ?? c.code
return String(v || '') === code
}
function getMatchKey(card) {
const c = card || {}
const v = c.match_key ?? c.key ?? c.card_key ?? c.type ?? c.type_code ?? c.card_type_code ?? c.position ?? c.code ?? c.product_id ?? c.reward_id ?? c.prize_id ?? c.card_id ?? c.name ?? c.title
return String(v || '')
}
function getDeckFromEntry(entry) {
const e = entry || {}
return Array.isArray(e.all_cards) ? e.all_cards : []
}
function restoreOrInitLocalGame() {
const entry = gameEntry.value || null
const aid = activityId.value || ''
const issueId = String(gameIssueId.value || currentIssueId.value || '')
if (!entry || !entry.game_id) return
pickedHandIndex.value = -1
const deck = normalizeAllCards(getDeckFromEntry(entry))
const cachedHand = Array.isArray(entry.hand) ? entry.hand : null
const cachedDeckIndexRaw = entry.deck_index ?? entry.deckIndex
const cachedChanceRaw = entry.chance
const cachedPairsRaw = entry.total_pairs ?? entry.totalPairs
if (cachedHand) {
hand.value = cachedHand
const idx = Number(cachedDeckIndexRaw)
const minIdx = cachedHand.length
const nextIdx = Number.isFinite(idx) ? Math.max(minIdx, idx) : minIdx
deckIndex.value = Math.min(deck.length, Math.max(0, nextIdx))
chance.value = Number.isFinite(Number(cachedChanceRaw)) ? Number(cachedChanceRaw) : 0
totalPairs.value = Number.isFinite(Number(cachedPairsRaw)) ? Number(cachedPairsRaw) : 0
gameFinished.value = false
if (deck !== entry.all_cards) gameEntry.value = { ...entry, all_cards: deck }
return
}
const initialHand = deck.slice(0, 9)
const selectedCode = getSelectedCodeFromEntry(entry)
const initialChance = initialHand.reduce((acc, it) => acc + (isSelectedCard(it, selectedCode) ? 1 : 0), 0)
hand.value = initialHand
deckIndex.value = initialHand.length
chance.value = initialChance
totalPairs.value = 0
gameFinished.value = false
writeMatchingGameCacheEntry(aid, issueId, {
...entry,
all_cards: deck,
hand: initialHand,
deck_index: initialHand.length,
chance: initialChance,
total_pairs: 0,
ts: Date.now()
})
}
function persistLocalGame() {
const aid = activityId.value || ''
const issueId = String(gameIssueId.value || currentIssueId.value || '')
const entry = gameEntry.value || null
if (!entry || !entry.game_id) return
const deck = normalizeAllCards(getDeckFromEntry(entry))
const next = {
...entry,
all_cards: deck,
hand: Array.isArray(hand.value) ? hand.value : [],
deck_index: Number(deckIndex.value || 0),
chance: Number(chance.value || 0),
total_pairs: Number(totalPairs.value || 0),
ts: Date.now()
}
gameEntry.value = next
writeMatchingGameCacheEntry(aid, issueId, next)
}
function eliminateAllPairs() {
const cards = Array.isArray(hand.value) ? [...hand.value] : []
let removed = 0
for (;;) {
const counts = new Map()
for (const c of cards) {
const k = getMatchKey(c)
if (!k) continue
counts.set(k, (counts.get(k) || 0) + 1)
}
let targetKey = ''
for (const [k, n] of counts.entries()) {
if (n >= 2) { targetKey = k; break }
}
if (!targetKey) break
let first = -1
let second = -1
for (let i = 0; i < cards.length; i++) {
if (getMatchKey(cards[i]) !== targetKey) continue
if (first === -1) first = i
else { second = i; break }
}
if (first === -1 || second === -1) break
const a = Math.max(first, second)
const b = Math.min(first, second)
cards.splice(a, 1)
cards.splice(b, 1)
removed += 1
}
if (removed > 0) {
hand.value = cards
totalPairs.value = Number(totalPairs.value || 0) + removed
chance.value = Number(chance.value || 0) + removed
}
return removed
}
function canEliminateNow() {
const cards = Array.isArray(hand.value) ? hand.value : []
const counts = new Map()
for (const c of cards) {
const k = getMatchKey(c)
if (!k) continue
const n = (counts.get(k) || 0) + 1
if (n >= 2) return true
counts.set(k, n)
}
return false
}
function canDrawOne() {
const entry = gameEntry.value || null
if (!entry) return false
const deck = getDeckFromEntry(entry)
return Number(deckIndex.value || 0) < deck.length
}
function drawOne() {
const entry = gameEntry.value || null
if (!entry) return null
const deck = getDeckFromEntry(entry)
const idx = Number(deckIndex.value || 0)
if (idx >= deck.length) return null
const next = deck[idx]
hand.value = [...(Array.isArray(hand.value) ? hand.value : []), next]
deckIndex.value = idx + 1
return next
}
function manualDraw() {
if (gameLoading.value) return
if (!canManualDraw.value) return
drawOne()
chance.value = Math.max(0, Number(chance.value || 0) - 1)
pickedHandIndex.value = -1
persistLocalGame()
}
async function autoDrawIfStuck() {
const entry = gameEntry.value || null
const gameId = entry && entry.game_id ? String(entry.game_id) : ''
if (!gameId) return
if (gameFinished.value) return
if (canEliminateNow()) return
let guard = 0
while (!canEliminateNow() && Number(chance.value || 0) > 0 && canDrawOne()) {
guard += 1
if (guard > 1000) throw new Error('自动摸牌次数过多')
drawOne()
chance.value = Math.max(0, Number(chance.value || 0) - 1)
pickedHandIndex.value = -1
persistLocalGame()
}
if (!canEliminateNow() && (Number(chance.value || 0) <= 0 || !canDrawOne())) {
await finishAndReport()
}
}
async function onCellTap(cell) {
if (gameLoading.value) return
if (!cell || cell.empty) return
const hi = Number(cell.handIndex)
if (!Number.isFinite(hi) || hi < 0) return
const cards = Array.isArray(hand.value) ? hand.value : []
if (!cards[hi]) return
const picked = Number(pickedHandIndex.value || -1)
if (picked < 0) {
pickedHandIndex.value = hi
return
}
if (picked === hi) {
pickedHandIndex.value = -1
return
}
const a = cards[picked]
const b = cards[hi]
const ka = getMatchKey(a)
const kb = getMatchKey(b)
if (ka && kb && ka === kb) {
const next = [...cards]
const max = Math.max(picked, hi)
const min = Math.min(picked, hi)
next.splice(max, 1)
next.splice(min, 1)
hand.value = next
totalPairs.value = Number(totalPairs.value || 0) + 1
chance.value = Number(chance.value || 0) + 1
pickedHandIndex.value = -1
persistLocalGame()
await autoDrawIfStuck()
return
}
pickedHandIndex.value = hi
uni.showToast({ title: '不相同', icon: 'none' })
}
async function finishAndReport() {
const aid = activityId.value || ''
const issueId = String(gameIssueId.value || currentIssueId.value || '')
const entry = gameEntry.value || null
const gameId = entry && entry.game_id ? String(entry.game_id) : ''
if (!gameId) return
await checkMatchingGame(gameId, Number(totalPairs.value || 0))
clearMatchingGameCacheEntry(aid, issueId)
gameFinished.value = true
closeGame()
uni.showModal({
title: '游戏结束',
content: `总对数:${Number(totalPairs.value || 0)}`,
showCancel: false
})
}
async function advanceOne() {
if (gameLoading.value) return
const entry = gameEntry.value || null
const gameId = entry && entry.game_id ? String(entry.game_id) : ''
if (!gameId) return
gameLoading.value = true
gameError.value = ''
try {
const removed = eliminateAllPairs()
if (removed > 0) {
persistLocalGame()
return
}
if (!canEliminateNow()) {
await autoDrawIfStuck()
return
}
persistLocalGame()
} catch (e) {
gameError.value = e?.message || '操作失败'
} finally {
gameLoading.value = false
}
}
async function autoRun() {
if (gameLoading.value) return
const entry = gameEntry.value || null
const gameId = entry && entry.game_id ? String(entry.game_id) : ''
if (!gameId) return
gameLoading.value = true
gameError.value = ''
try {
let guard = 0
for (;;) {
guard += 1
if (guard > 1000) throw new Error('自动进行次数过多')
const removed = eliminateAllPairs()
if (removed > 0) {
persistLocalGame()
continue
}
await autoDrawIfStuck()
break
}
} catch (e) {
gameError.value = e?.message || '操作失败'
} finally {
gameLoading.value = false
}
}
async function onParticipate() {
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 }
const latest = syncResumeGame(aid)
if (latest && latest.entry && latest.entry.game_id) {
await openGame(latest)
return
}
await fetchCardTypes()
if (!selectedCardType.value) {
uni.showToast({ title: '请选择卡牌类型', icon: 'none' })
return
}
coupons.value = []
propCards.value = []
selectedCoupon.value = null
selectedCard.value = null
paymentVisible.value = true
fetchCoupons()
fetchPropCards()
}
async function applyResumeEntry(entry) {
if (!entry) return
await fetchCardTypes()
const pos = String(entry.position || '')
if (pos) selectedCardTypeCode.value = pos
}
async function onResumeGame() {
const aid = activityId.value || ''
const latest = syncResumeGame(aid)
if (!latest || !latest.entry || !latest.entry.game_id) return
await openGame(latest)
}
function selectCardType(it) {
selectedCardTypeCode.value = it && it.code ? String(it.code) : ''
}
async function fetchCardTypes() {
if (cardTypesLoading.value) return
cardTypesLoading.value = true
try {
const res = await getMatchingCardTypes()
cardTypes.value = normalizeCardTypes(res)
} catch (e) {
cardTypes.value = []
selectedCardTypeCode.value = ''
} finally {
cardTypesLoading.value = false
}
}
async function onPaymentConfirm(data) {
selectedCoupon.value = data?.coupon || null
selectedCard.value = data?.card || null
paymentVisible.value = false
await doDraw()
}
async function doDraw() {
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) return
const openid = uni.getStorageSync('openid')
if (!openid) { uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' }); return }
uni.showLoading({ title: '创建订单...' })
try {
if (!selectedCardType.value) {
uni.hideLoading()
uni.showToast({ title: '请选择卡牌类型', icon: 'none' })
return
}
// 1. 调用 createMatchingPreorder 创建对对碰订单(同时返回游戏数据)
const preRes = await createMatchingPreorder({
issue_id: Number(iid),
position: String(selectedCardType.value.code || ''),
coupon_id: selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0,
item_card_id: selectedCard.value?.id ? Number(selectedCard.value.id) : 0
})
if (!preRes) throw new Error('创建订单失败')
// 2. 提取订单号和游戏数据
const orderNo = preRes.order_no || preRes.data?.order_no || preRes.result?.order_no || preRes.orderNo
if (!orderNo) throw new Error('未获取到订单号')
const gameId = preRes.game_id || preRes.data?.game_id || preRes.result?.game_id || preRes.gameId
const allCards = normalizeAllCards(preRes.all_cards || preRes.data?.all_cards || preRes.result?.all_cards || [])
// 3. 用对对碰订单号调用微信支付
uni.showLoading({ title: '拉起支付...' })
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 || 'RSA',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
// 4. 支付成功后保存游戏数据到本地缓存
if (gameId) {
writeMatchingGameCacheEntry(aid, iid, {
game_id: String(gameId),
position: String(selectedCardType.value.code || ''),
all_cards: allCards,
ts: Date.now()
})
}
uni.hideLoading()
uni.showToast({ title: '支付成功', icon: 'success' })
// 5. 自动打开游戏
syncResumeGame(aid)
const latest = findLatestMatchingGameCacheEntry(aid)
if (latest && latest.entry && latest.entry.game_id) {
await openGame(latest)
}
} catch (e) {
uni.hideLoading()
if (e?.errMsg && String(e.errMsg).includes('cancel')) {
uni.showToast({ title: '支付已取消', icon: 'none' })
return
}
uni.showToast({ title: e?.message || '操作失败', icon: 'none' })
}
}
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 cents = (i.remaining !== undefined && i.remaining !== null) ? Number(i.remaining) : Number(i.amount ?? i.value ?? 0)
const yuan = isNaN(cents) ? 0 : (cents / 100)
return {
id: i.id ?? i.coupon_id ?? String(idx),
name: i.name ?? i.title ?? '优惠券',
amount: Number(yuan).toFixed(2)
}
})
} catch (e) {
coupons.value = []
}
}
async function fetchPropCards() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
// Status 1 = Unused
const res = await getItemCards(user_id, 1)
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) => {
const count = i.count ?? i.remaining ?? 1
const name = i.name ?? i.title ?? i.card_name ?? '道具卡'
return {
id: i.id ?? i.card_id ?? i.item_card_id ?? String(idx),
name: `${name} (×${count})`,
rawName: name,
count: count
}
})
} catch (e) {
propCards.value = []
}
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
syncResumeGame(id)
fetchDetail(id)
fetchIssues(id)
}
fetchCardTypes()
})
</script>
<style lang="scss" scoped>
/* ============================================
对对碰活动页面 - 高级设计重构 (SCSS Integration)
============================================ */
.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;
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-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;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2rpx;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.8), transparent);
}
}
.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;
/* 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);
}
.card-types {
padding: 0 $spacing-lg;
margin-bottom: $spacing-lg;
}
.card-types-title {
font-size: $font-md;
font-weight: 700;
color: $text-main;
margin-bottom: $spacing-sm;
}
.card-types-loading,
.card-types-empty {
font-size: $font-sm;
color: $text-sub;
padding: $spacing-md 0;
}
.card-types-scroll {
width: 100%;
}
.card-types-row {
display: flex;
gap: $spacing-md;
padding-bottom: $spacing-xs;
}
.card-type-item {
width: 160rpx;
flex: 0 0 auto;
background: rgba($bg-card, 0.9);
border-radius: $radius-lg;
padding: $spacing-sm;
border: 2rpx solid rgba(0,0,0,0.05);
box-shadow: $shadow-sm;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
position: relative;
}
.card-type-item.active {
border-color: rgba($brand-primary, 0.6);
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.18);
}
.card-type-img {
width: 120rpx;
height: 120rpx;
border-radius: $radius-md;
background: $bg-secondary;
}
.card-type-name {
margin-top: $spacing-sm;
font-size: $font-sm;
font-weight: 700;
color: $text-main;
text-align: center;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-type-qty {
margin-top: 6rpx;
font-size: 22rpx;
color: $text-sub;
}
/* Game Modal Styles */
.game-scroll-list {
max-height: 60vh;
padding: $spacing-lg;
}
.game-empty-state {
padding: 60rpx 0 20rpx;
text-align: center;
color: $text-tertiary;
font-size: $font-sm;
}
.game-info-card {
display: flex;
background: #FFFFFF;
padding: $spacing-lg;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
align-items: center;
margin-bottom: $spacing-lg;
}
.game-info-content {
flex: 1;
}
.game-info-title {
font-size: $font-md;
font-weight: 600;
color: $text-main;
margin-bottom: $spacing-xs;
}
.game-info-meta {
display: flex;
gap: $spacing-md;
font-size: $font-sm;
color: $text-sub;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
padding: 0 4rpx;
position: relative;
z-index: 20;
}
.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; }
}
.record-count {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
padding: 2rpx $spacing-sm;
border-radius: $radius-sm;
}
.match-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: $spacing-sm;
}
.match-cell {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: $radius-md;
overflow: hidden;
background: rgba(255,255,255,0.75);
border: 2rpx solid rgba(0,0,0,0.06);
box-shadow: $shadow-xs;
}
.match-cell.chosen {
border-color: rgba($brand-primary, 0.8);
box-shadow: 0 10rpx 22rpx rgba($brand-primary, 0.18);
}
.match-cell.picked {
border-color: rgba($accent-gold, 0.9);
box-shadow: 0 10rpx 22rpx rgba($accent-gold, 0.22);
}
.match-cell.empty {
background: rgba(255,255,255,0.35);
border-style: dashed;
}
.match-cell-img {
width: 100%;
height: 100%;
background: $bg-secondary;
}
.match-cell-type {
position: absolute;
left: 8rpx;
bottom: 8rpx;
max-width: 90%;
font-size: 20rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.5);
padding: 2rpx 8rpx;
border-radius: 10rpx;
pointer-events: none;
@include text-ellipsis(1);
}
/* 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 {
position: fixed;
left: 32rpx;
right: 32rpx;
bottom: calc(40rpx + env(safe-area-inset-bottom));
z-index: 100;
animation: slideUp 0.6s cubic-bezier(0.23, 1, 0.32, 1) backwards;
}
.float-bar-inner {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(30rpx);
padding: 24rpx 40rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.float-price {
display: flex;
align-items: baseline;
color: $text-main;
font-weight: 800;
min-width: 0;
}
.float-price .currency { font-size: 26rpx; margin-right: 4rpx; color: $brand-primary; }
.float-price .amount { font-size: 44rpx; font-weight: 900; font-family: 'DIN Alternate', sans-serif; color: $brand-primary; }
.float-price .unit { font-size: 24rpx; color: $text-sub; margin-left: 4rpx; font-weight: 600; }
.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: slideUp 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;
}
.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; }
.stagger-3 { animation-delay: 0.3s; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40rpx); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
@keyframes slideUp {
from { transform: translateY(20rpx); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.action-btn {
height: 88rpx;
line-height: 88rpx;
padding: 0 56rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
position: relative;
overflow: hidden;
&.primary {
background: $gradient-brand !important;
color: #fff !important;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.35);
&::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: btnShine 4s infinite cubic-bezier(0.19, 1, 0.22, 1);
pointer-events: none;
}
}
&.secondary {
background: #1A1A1A !important;
color: $accent-gold !important;
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.15);
}
&:active {
transform: scale(0.92);
}
}
@keyframes btnShine {
0% { left: -150%; }
100% { left: 150%; }
}
.flip-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999;
}
.flip-mask {
position: absolute; top: 0; bottom: 0; width: 100%; background: rgba(0,0,0,0.85);
backdrop-filter: blur(10rpx);
animation: fadeIn 0.3s ease-out;
}
.flip-content {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
padding: 40rpx;
justify-content: center;
animation: scaleIn 0.3s ease-out;
}
.close-btn {
margin-top: 60rpx;
background: #fff;
color: #333;
border-radius: 100rpx;
font-weight: 700;
width: 50%;
height: 80rpx;
line-height: 80rpx;
align-self: center;
box-shadow: 0 10rpx 30rpx rgba(255,255,255,0.15);
transition: all 0.2s;
&:active { transform: scale(0.95); }
}
/* Animation Utilities */
.animate-stagger {
animation: fadeInUp 0.5s ease-out backwards;
animation-delay: var(--delay, 0s);
}
</style>