邹方成 be57eda392 fix(orders): 修复订单列表显示问题并优化详情页展示
修复订单列表不显示 source_type=3 订单的问题,支持对对碰等玩法订单
优化订单标题显示逻辑,移除内部标识并添加保底显示
优化订单详情页,当没有实物商品时显示活动信息
重构订单类型判断逻辑,支持更多玩法类型
2025-12-22 14:40:53 +08:00

2053 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page-wrapper">
<view class="bg-decoration">
<view class="orb orb-1"></view>
<view class="orb orb-2"></view>
</view>
<view class="page-bg">
<image class="bg-image" :src="coverUrl" mode="aspectFill" />
<view class="bg-mask"></view>
</view>
<scroll-view class="main-scroll" scroll-y>
<view class="header-card animate-enter">
<image class="header-cover" :src="coverUrl" mode="aspectFill" />
<view class="header-info">
<view class="header-title">{{ detail.name || detail.title || '-' }}</view>
<view class="header-price-row" v-if="detail.price_draw !== undefined">
<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>
<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>
<view class="section-container animate-enter stagger-2">
<view class="issue-header">
<view class="issue-switch-btn" @tap="prevIssue">
<text class="arrow">◀</text>
</view>
<view class="issue-info-center">
<text class="issue-current-text">{{ currentIssueTitle }}</text>
<text class="issue-status-badge">{{ currentIssueStatusText }}</text>
</view>
<view class="issue-switch-btn" @tap="nextIssue">
<text class="arrow">▶</text>
</view>
</view>
<view class="modern-tabs">
<view class="tab-item" :class="{ active: tabActive === 'pool' }" @tap="tabActive = 'pool'">
本机奖池
<view v-if="tabActive === 'pool'" class="active-dot"></view>
</view>
<view class="tab-item" :class="{ active: tabActive === 'records' }" @tap="tabActive = 'records'">
购买记录
<view v-if="tabActive === 'records'" class="active-dot"></view>
</view>
</view>
<view v-show="tabActive === 'pool'">
<view class="section-header">
<text class="section-title">奖池配置</text>
<text class="section-more" @tap="openRewardsPopup">查看全部</text>
</view>
<scroll-view v-if="currentIssueRewards.length > 0" class="preview-scroll" scroll-x>
<view class="preview-item" v-for="(item, idx) in currentIssueRewards" :key="item.id || idx">
<view class="prize-tag" :class="{ 'tag-boss': item.boss }">{{ item.boss ? 'BOSS' : '赏' }}</view>
<image class="preview-img" :src="item.image" mode="aspectFill" />
<view class="preview-name">{{ item.title }}</view>
</view>
</scroll-view>
<view v-else class="empty-state">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无奖励配置</text>
</view>
</view>
<view v-show="tabActive === 'records'">
<view class="records-list" v-if="winRecords.length">
<view v-for="(it, idx) in winRecords" :key="it.id" class="record-item">
<image class="record-img" :src="it.image" mode="aspectFill" />
<view class="record-info">
<view class="record-title">{{ it.title }}</view>
<view class="record-meta">
<text class="record-count">x{{ it.count }}</text>
<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 style="height: 180rpx;"></view>
</scroll-view>
<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>
</view>
<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="rewards-list">
<view v-if="gameLoading" class="rewards-empty">加载中...</view>
<view v-else-if="gameError" class="rewards-empty">{{ gameError }}</view>
<view v-else>
<view class="record-item">
<view class="record-info">
<view class="record-title">总对数:{{ totalPairs }} 摸牌机会:{{ chance }}</view>
<view class="record-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>
<view v-if="rewardsVisible" class="rewards-overlay" @touchmove.stop.prevent>
<view class="rewards-mask" @tap="closeRewardsPopup"></view>
<view class="rewards-panel" @tap.stop>
<view class="rewards-header">
<text class="rewards-title">{{ currentIssueTitle }} · 奖品与概率</text>
<text class="rewards-close" @tap="closeRewardsPopup">×</text>
</view>
<scroll-view scroll-y class="rewards-list">
<view v-for="(item, idx) in currentIssueRewards" :key="item.id || idx" class="rewards-item">
<image class="rewards-thumb" :src="item.image" mode="aspectFill" />
<view class="rewards-info">
<view class="rewards-name-row">
<text class="rewards-name">{{ item.title || '-' }}</text>
<view class="rewards-tag" v-if="item.boss">BOSS</view>
</view>
<text class="rewards-percent">概率 {{ formatPercent(item.percent) }}</text>
</view>
</view>
<view v-if="!currentIssueRewards.length" class="rewards-empty">暂无奖品数据</view>
</scroll-view>
</view>
</view>
<PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="coupons"
:propCards="propCards"
:showCards="true"
@confirm="onPaymentConfirm"
/>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PaymentPopup from '../../../components/PaymentPopup.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame } 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 currentIssueTitle = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
return (cur && (cur.title || ('第' + (cur.no || '-') + '期'))) || '-'
})
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)
}))
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)
}
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)
}
function formatPercent(v) {
const n = Number(v)
if (!Number.isFinite(n)) return '0%'
return `${n}%`
}
function openRewardsPopup() {
rewardsVisible.value = true
}
function closeRewardsPopup() {
rewardsVisible.value = false
}
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 {
const res = await getItemCards(user_id, 0)
let list = []
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
propCards.value = list.map((i, idx) => ({
id: i.id ?? i.card_id ?? i.item_card_id ?? String(idx),
name: i.name ?? i.title ?? i.card_name ?? '道具卡'
}))
} 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;
display: flex;
flex-direction: column;
justify-content: flex-start;
min-height: 180rpx;
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);
}
.section-container {
margin: 0 $spacing-lg $spacing-lg;
background: rgba(255, 255, 255, 0.9);
border-radius: $radius-xl;
padding: $spacing-lg;
box-shadow: $shadow-sm;
backdrop-filter: blur(10rpx);
}
.section-more {
font-size: $font-sm;
color: $text-tertiary;
display: flex;
align-items: center;
&::after {
content: '>';
font-family: monospace;
margin-left: 6rpx;
font-weight: 700;
}
}
.issue-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
background: $bg-grey;
border-radius: $radius-round;
padding: 10rpx;
border: 1rpx solid $border-color-light;
}
.issue-switch-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background: $bg-card;
border-radius: 50%;
box-shadow: $shadow-sm;
transition: all 0.2s;
color: $text-secondary;
&:active {
transform: scale(0.9);
background: $bg-secondary;
color: $brand-primary;
}
}
.arrow {
font-size: $font-sm;
font-weight: 800;
}
.issue-info-center {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.issue-current-text {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
}
.issue-status-badge {
font-size: $font-xs;
color: $uni-color-success;
background: rgba($uni-color-success, 0.1);
padding: 2rpx $spacing-md;
border-radius: $radius-round;
margin-top: 4rpx;
font-weight: 600;
}
.preview-scroll {
white-space: nowrap;
margin: 0 -$spacing-lg;
padding: 0 $spacing-lg;
width: calc(100% + 40rpx);
}
.preview-item {
display: inline-block;
width: 160rpx;
margin-right: $spacing-lg;
vertical-align: top;
position: relative;
transition: transform 0.2s;
&:active {
transform: scale(0.96);
}
&:last-child {
margin-right: 40rpx;
}
}
.preview-img {
width: 160rpx;
height: 160rpx;
border-radius: $radius-lg;
background: $bg-secondary;
margin-bottom: $spacing-sm;
box-shadow: $shadow-sm;
border: 1rpx solid rgba(0,0,0,0.03);
}
.preview-name {
font-size: $font-sm;
color: $text-secondary;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
font-weight: 500;
}
.prize-tag {
position: absolute;
top: 10rpx;
left: 10rpx;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: $font-xs;
padding: 4rpx $spacing-sm;
border-radius: $radius-sm;
z-index: 10;
font-weight: 700;
backdrop-filter: blur(4rpx);
transform: scale(0.9);
transform-origin: top left;
}
.prize-tag.tag-boss {
background: $gradient-brand;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.4);
}
/* 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;
}
/* 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;
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; }
}
/* 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;
}
.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 */
.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;
}
.float-bar-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-lg;
}
.float-price {
display: flex;
align-items: baseline;
color: $text-main;
font-weight: 800;
min-width: 0;
}
.currency { font-size: $font-md; color: $brand-primary; }
.amount { font-size: 44rpx; margin: 0 6rpx; color: $brand-primary; font-family: 'DIN Alternate', sans-serif; }
.unit { font-size: $font-sm; color: $text-tertiary; 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-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: 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;
}
&.secondary {
background: rgba($bg-card, 0.9);
color: $text-main;
border: 2rpx solid rgba($brand-primary, 0.25);
box-shadow: $shadow-sm;
padding: 0 40rpx;
}
&: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;
}
@keyframes shine {
0% { left: -100%; }
50%, 100% { left: 200%; }
}
.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>