2606 lines
71 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">
<view class="bg-decoration"></view>
<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="tabActive"
:tabs="[{key: 'pool', label: '本机奖池'}, {key: 'records', label: '购买记录'}]"
>
<!-- 奖池预览 -->
<RewardsPreview
v-if="tabActive === 'pool'"
:rewards="previewRewards"
:grouped="!['match', 'matching'].includes(detail?.play_type)"
:play-type="detail?.play_type || 'normal'"
@view-all="openRewardsPopup"
/>
<!-- 购买记录 -->
<RecordsList
v-if="tabActive === 'records'"
:records="winRecords"
/>
</ActivityTabs>
<view style="height: 180rpx;"></view>
<template #footer>
<view class="float-bar">
<view class="float-bar-inner">
<!-- 左侧价格区域 -->
<view class="float-left">
<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 class="float-badges">
<view v-if="gamePassRemaining > 0" class="game-pass-badge" @tap="onParticipate">
<text class="badge-icon">🎮</text>
<text class="badge-text">{{ gamePassRemaining }}</text>
</view>
<view class="game-pass-buy-btn" @tap="openPurchasePopup">
<text>购买次数</text>
</view>
</view>
</view>
<!-- 右侧操作按钮 -->
<view v-if="hasResumeGame" class="action-btn secondary" @tap="onResumeGame">
继续游戏
</view>
<view v-else class="action-btn primary" @tap="onParticipate">
立即参与
<view class="btn-shine"></view>
</view>
</view>
</view>
</template>
<template #modals>
<!-- 游戏弹窗 - 全屏沉浸式 -->
<view v-if="gameVisible" class="game-overlay" @touchmove.stop.prevent>
<view class="game-mask"></view>
<view class="game-fullscreen" @tap.stop>
<!-- 顶部标题栏 -->
<view class="game-topbar">
<text class="game-topbar-title">对对碰游戏</text>
<view class="game-close-btn" @tap="closeGame">
<text>×</text>
</view>
</view>
<!-- 游戏信息 -->
<view class="game-stats glass-card">
<button class="game-btn-draw" @tap="manualDraw" :disabled="gameLoading || !canManualDraw">
<text>摸牌</text>
</button>
<view class="stat-item">
<text class="stat-label">总对数</text>
<text class="stat-value">{{ totalPairs }}</text>
</view>
<view class="stat-item">
<text class="stat-label">{{ countdownSeconds > 0 ? '倒计时' : '摸牌机会' }}</text>
<text class="stat-value" :class="{ 'countdown': countdownSeconds > 0 }">
{{ countdownSeconds > 0 ? `${countdownSeconds}s` : chance }}
</text>
</view>
<view class="stat-item">
<text class="stat-label">牌组剩余</text>
<text class="stat-value">{{ deckRemaining }}</text>
</view>
</view>
<!-- 游戏区域 -->
<view class="game-content">
<view v-if="gameLoading" class="game-loading">
<text class="loading-icon">⏳</text>
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="gameError" class="game-error">
<text class="error-icon">⚠️</text>
<text class="error-text">{{ gameError }}</text>
</view>
<view v-else class="game-board glass-card">
<view class="match-grid-fullscreen">
<view
v-for="(cell, idx) in handGridCells"
:key="idx"
class="match-cell-large"
:class="{ empty: cell.empty, chosen: cell.isChosen, picked: cell.isPicked }"
@tap="() => onCellTap(cell)"
>
<image v-if="cell.image" class="match-cell-img-large" :src="cell.image" mode="aspectFill" />
<view v-else class="match-cell-img-large"></view>
<image v-if="cell.empty" class="match-cell-logo" src="/static/logo.png" mode="aspectFit" />
<text v-if="!cell.empty && cell.type" class="match-cell-type-large">{{ cell.type }}</text>
</view>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="game-actions">
<button class="game-btn btn-primary" @tap="advanceOne" :disabled="gameLoading">
<text>下一步</text>
</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"
:gamePasses="gamePasses"
@confirm="onPaymentConfirm"
/>
<GamePassPurchasePopup
v-model:visible="purchasePopupVisible"
:activity-id="activityId"
@success="onPurchaseSuccess"
/>
<RulesPopup
v-model:visible="rulesVisible"
:content="detail.gameplay_intro"
/>
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
<!-- 开奖结果弹窗 -->
<LotteryResultPopup
v-model:visible="resultVisible"
:results="resultItems"
@close="onResultClose"
/>
</template>
</ActivityPageLayout>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PaymentPopup from '../../../components/PaymentPopup.vue'
import GamePassPurchasePopup from '../../../components/GamePassPurchasePopup.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 RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import LotteryResultPopup from '@/components/activity/LotteryResultPopup.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame, getIssueDrawLogs, getMatchingGameCards, getGamePasses } from '../../../api/appUser'
import { levelToAlpha } from '@/utils/activity'
import { vibrateShort } from '@/utils/vibrate.js'
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 rulesVisible = ref(false)
const cabinetVisible = ref(false)
const resultVisible = ref(false)
const resultItems = ref([])
const gamePasses = ref(null) // 次数卡数据 { total_remaining, passes }
const gamePassRemaining = computed(() => gamePasses.value?.total_remaining || 0)
const useGamePassFlag = ref(false) // 是否使用次数卡支付
const purchasePopupVisible = 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 countdownTimer = ref(null)
const countdownSeconds = ref(0)
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] : []
})
// 用于奖池预览的 rewards已排序
const previewRewards = computed(() => {
const isMatchType = ['match', 'matching'].includes(detail.value?.play_type)
if (isMatchType) {
// 对对碰模式:按 min_score 升序
const sorted = [...currentIssueRewards.value].sort((a, b) => (a.min_score - b.min_score))
console.log('[对对碰] previewRewards 排序后:', sorted.map(r => ({
name: r.title,
min_score: r.min_score,
weight: r.weight,
percent: r.percent
})))
return sorted
} else {
// 普通模式:返回原数组
return currentIssueRewards.value
}
})
const rewardGroups = computed(() => {
const isMatchType = ['match', 'matching'].includes(detail.value?.play_type)
// 对对碰模式:不分组,直接按 min_score 平铺所有奖品
if (isMatchType) {
// 先按 min_score 升序排序
const sortedRewards = [...currentIssueRewards.value].sort((a, b) => (a.min_score - b.min_score))
// 将每个奖品作为一个单独的分组
const groups = sortedRewards.map(item => ({
level: `${item.min_score}对子`,
rewards: [item],
totalPercent: item.percent.toFixed(1)
}))
console.log('[对对碰] rewardGroups 分组后:', groups.map(g => ({
level: g.level,
count: g.rewards.length,
totalPercent: g.totalPercent,
firstItem: g.rewards[0]?.title
})))
return groups
}
// 普通模式:按原来的分组逻辑
const groups = {}
currentIssueRewards.value.forEach(item => {
let level = item.level || '赏'
// 普通模式:只显示 min_score > 0 的奖品
if (item.min_score > 0 && level !== 'BOSS') {
level = `${item.min_score}对子`
}
if (!groups[level]) groups[level] = []
groups[level].push(item)
})
return Object.keys(groups).sort((a, b) => {
// 普通模式Last 和 BOSS 优先
if (a === 'Last' || a === 'BOSS') return -1
if (b === 'Last' || b === 'BOSS') return 1
// 普通模式:分组之间按该组最小 weight 排序(升序)
const minWeightA = Math.min(...groups[a].map(item => item.weight || 0))
const minWeightB = Math.min(...groups[b].map(item => item.weight || 0))
return minWeightA - minWeightB
}).map(key => {
const rewards = groups[key]
// 普通模式:分组内按 weight 升序排列
rewards.sort((a, b) => (a.weight - b.weight))
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()
}
function cleanAvatar(avatar) {
if (!avatar) return ''
// 如果是 base64 格式,确保格式正确
const avatarStr = String(avatar).trim()
// 检查是否已经是 data:image 格式
if (avatarStr.startsWith('data:image/')) {
return avatarStr
}
// 如果是 http(s) URL直接返回
if (avatarStr.startsWith('http://') || avatarStr.startsWith('https://')) {
return avatarStr
}
// 如果是相对路径,直接返回
if (avatarStr.startsWith('/')) {
return avatarStr
}
// 其他情况,可能是不完整的 base64尝试修复
// 如果不包含 data:image 前缀,添加默认的 png 前缀
if (avatarStr.match(/^[A-Za-z0-9+/=]+$/)) {
// 看起来像 base64 编码
return `data:image/png;base64,${avatarStr}`
}
return avatarStr
}
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, playType = 'normal') {
const arr = unwrap(list)
const items = arr.map((i, idx) => ({
...i, // Spread original properties first
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),
min_score: Number(i.min_score) || 0, // Extract min_score
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
}))
// 根据 play_type 决定排序方式
if (['match', 'matching'].includes(playType)) {
// 对对碰:按 min_score 升序排列,不过滤 min_score=0 的奖品
enriched.sort((a, b) => (a.min_score - b.min_score))
} else {
// 普通活动:按 weight 升序排列(从小到大)
enriched.sort((a, b) => (a.weight - b.weight))
}
return enriched
}
async function fetchRewardsForIssues(activityId) {
const list = issues.value || []
const promises = list.map(it => getActivityIssueRewards(activityId, it.id))
const results = await Promise.allSettled(promises)
// 获取 play_type
const playType = detail.value?.play_type || 'normal'
results.forEach((res, i) => {
const issueId = list[i] && list[i].id
if (!issueId) return
const value = res.status === 'fulfilled' ? normalizeRewards(res.value, playType) : []
rewardsMap.value = { ...(rewardsMap.value || {}), [issueId]: value }
})
}
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 : [])
// 不再聚合,直接使用原始记录列表,保留用户信息
winRecords.value = list.map(it => ({
id: it.id,
title: it.reward_name || it.title || it.name || '-',
image: it.reward_image || it.image || '',
count: 1,
// 用户信息
user_id: it.user_id,
user_name: it.user_name || '匿名用户',
avatar: cleanAvatar(it.avatar),
// 时间信息
created_at: it.created_at
}))
} 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 openRewardsPopup() {
rewardsVisible.value = true
}
function closeRewardsPopup() {
rewardsVisible.value = false
}
function showRules() {
rulesVisible.value = true
}
function goCabinet() {
cabinetVisible.value = true
}
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()
// 检查是否摸牌次数为0且无法继续配对
if (Number(chance.value || 0) <= 0 && !canEliminateNow()) {
// 使用 setTimeout 确保界面先显示出来,再启动倒计时
setTimeout(() => {
startCountdown()
}, 500)
}
}
function closeGame() {
gameVisible.value = false
gameLoading.value = false
gameError.value = ''
gameEntry.value = null
gameIssueId.value = ''
pickedHandIndex.value = -1
// 清理倒计时
clearCountdown()
}
function startCountdown() {
// 清除之前的倒计时
clearCountdown()
countdownSeconds.value = 3
uni.showToast({
title: `摸牌次数已用完,${countdownSeconds.value}秒后结束游戏`,
icon: 'none',
duration: 1000
})
countdownTimer.value = setInterval(() => {
countdownSeconds.value -= 1
if (countdownSeconds.value > 0) {
uni.showToast({
title: `${countdownSeconds.value}秒后结束游戏`,
icon: 'none',
duration: 900
})
} else {
// 倒计时结束,执行游戏结束逻辑
clearCountdown()
finishAndReport()
}
}, 1000)
}
function clearCountdown() {
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
countdownTimer.value = null
}
countdownSeconds.value = 0
}
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
vibrateShort()
drawOne()
chance.value = Math.max(0, Number(chance.value || 0) - 1)
pickedHandIndex.value = -1
persistLocalGame()
// 检查摸牌次数是否为0且无法继续配对
if (Number(chance.value || 0) <= 0 && !canEliminateNow()) {
startCountdown()
}
}
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
vibrateShort()
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()
// 检查配对后是否无法继续配对且摸牌次数为0
if (!canEliminateNow() && Number(chance.value || 0) <= 0) {
startCountdown()
}
// 移除自动摸牌,让玩家手动控制
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
try {
const checkRes = await checkMatchingGame(gameId, Number(totalPairs.value || 0))
clearMatchingGameCacheEntry(aid, issueId)
gameFinished.value = true
closeGame()
console.log('[对对碰] checkRes:', JSON.stringify(checkRes))
console.log('[对对碰] currentIssueRewards:', currentIssueRewards.value?.length, 'items')
console.log('[对对碰] rewardsMap keys:', Object.keys(rewardsMap.value || {}))
// 解析中奖结果 - 后端返回格式是 { reward: { reward_id, name, level, product_image } }
let wonItems = []
// 后端返回单个 reward 对象的情况
if (checkRes?.reward && checkRes.reward.reward_id) {
const reward = checkRes.reward
console.log('[对对碰] 检测到reward对象:', reward)
// 从本地 rewardsMap 查找图片
const allRewards = rewardsMap.value[issueId] || currentIssueRewards.value || []
console.log('[对对碰] 本地奖励数据:', allRewards.length, 'items')
const foundReward = allRewards.find(r =>
String(r.id) === String(reward.reward_id) ||
String(r.reward_id) === String(reward.reward_id)
)
console.log('[对对碰] 匹配到的奖励:', foundReward)
// 优先使用后端返回的 product_image没有则使用本地数据最后才用 logo
const rewardImage = cleanUrl(reward.product_image || foundReward?.image || foundReward?.pic || foundReward?.img || foundReward?.product_image || '')
wonItems = [{
title: reward.name || foundReward?.name || foundReward?.title || '神秘奖励',
image: rewardImage || '/static/logo.png',
reward_id: reward.reward_id
}]
}
// 处理数组格式(兼容)
else if (Array.isArray(checkRes) && checkRes.length > 0) {
wonItems = checkRes
} else if (checkRes?.list && checkRes.list.length > 0) {
wonItems = checkRes.list
} else if (checkRes?.data && Array.isArray(checkRes.data) && checkRes.data.length > 0) {
wonItems = checkRes.data
} else if (checkRes?.rewards && checkRes.rewards.length > 0) {
wonItems = checkRes.rewards
} else if (checkRes?.items && checkRes.items.length > 0) {
wonItems = checkRes.items
}
console.log('[对对碰] wonItems:', wonItems)
// 转换为 LotteryResultPopup 需要的格式
if (wonItems.length > 0) {
const allRewards = rewardsMap.value[issueId] || currentIssueRewards.value || []
resultItems.value = wonItems.map(item => {
// 如果已经有 title直接使用
if (item.title) return item
// 否则尝试从本地数据匹配
const found = allRewards.find(r =>
String(r.id) === String(item.reward_id) ||
String(r.reward_id) === String(item.reward_id)
)
// 优先使用后端返回的 product_image没有则使用本地数据最后才用 logo
const itemImage = cleanUrl(item.product_image || item.image || item.img || found?.image || found?.pic || found?.product_image || '')
return {
title: item.title || item.name || found?.name || found?.title || '神秘奖励',
image: itemImage || '/static/logo.png',
reward_id: item.reward_id || item.id
}
})
} else {
resultItems.value = []
}
console.log('[对对碰] resultItems:', resultItems.value)
// 显示结果弹窗
if (resultItems.value.length > 0) {
resultVisible.value = true
} else {
// 没有中奖物品时显示简单提示
uni.showModal({
title: '游戏结束',
content: `总对数:${Number(totalPairs.value || 0)}`,
showCancel: false
})
}
} catch (e) {
console.error('finishAndReport error', e)
uni.showToast({ title: e?.message || '结算失败', icon: 'none' })
}
}
function onResultClose() {
resultVisible.value = false
resultItems.value = []
}
async function advanceOne() {
if (gameLoading.value) return
vibrateShort()
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()) {
if (Number(chance.value || 0) > 0 && canDrawOne()) {
uni.showToast({ title: '请摸牌后再试', icon: 'none' })
} else if (Number(chance.value || 0) <= 0 && !canDrawOne()) {
// 摸牌次数为0且无法继续抽牌再次确认场上无法配对才结束游戏
if (!canEliminateNow()) {
await finishAndReport()
}
}
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() {
vibrateShort()
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
const token = uni.getStorageSync('token')
// 使用统一的手机号绑定检查
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
if (!token || !hasPhoneBound) {
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() {
vibrateShort()
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
useGamePassFlag.value = data?.useGamePass || false
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: useGamePassFlag.value ? 0 : (selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0),
item_card_id: useGamePassFlag.value ? 0 : (selectedCard.value?.id ? Number(selectedCard.value.id) : 0),
use_game_pass: useGamePassFlag.value
})
if (!preRes) throw new Error('创建订单失败')
// 2. 提取订单号和游戏ID注意all_cards 不再在这里返回)
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
if (!gameId) throw new Error('未获取到游戏ID')
// 3. 判断支付方式:次数卡已直接支付,微信需要拉起支付
const payStatus = preRes.pay_status || preRes.data?.pay_status || 1
if (useGamePassFlag.value || payStatus === 2) {
// 次数卡支付:订单已经是已支付状态,直接获取游戏数据
uni.showLoading({ title: '加载游戏...' })
// 刷新次数卡余额
await fetchGamePasses()
} else {
// 微信支付流程
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
})
})
uni.showLoading({ title: '加载游戏...' })
}
// 4. 获取游戏数据
const cardsRes = await getMatchingGameCards(gameId)
if (!cardsRes) throw new Error('获取游戏数据失败')
const allCards = normalizeAllCards(cardsRes.all_cards || cardsRes.data?.all_cards || [])
if (!allCards.length) throw new Error('游戏数据为空')
// 5. 保存游戏数据到本地缓存
writeMatchingGameCacheEntry(aid, iid, {
game_id: String(gameId),
position: String(selectedCardType.value.code || ''),
all_cards: allCards,
ts: Date.now()
})
uni.hideLoading()
uni.showToast({ title: useGamePassFlag.value ? '开始游戏' : '支付成功', icon: 'success' })
// 6. 自动打开游戏
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 fetchGamePasses() {
const aid = activityId.value || ''
if (!aid) return
try {
const res = await getGamePasses(Number(aid))
gamePasses.value = res || null
} catch (e) {
gamePasses.value = null
}
}
function openPurchasePopup() {
purchasePopupVisible.value = true
}
function onPurchaseSuccess() {
// 购买成功,刷新次数卡余额
fetchGamePasses()
}
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
// Group identical cards by name
const groupedMap = new Map()
list.forEach((i, idx) => {
const name = i.name ?? i.title ?? i.card_name ?? '道具卡'
if (!groupedMap.has(name)) {
groupedMap.set(name, {
id: i.id ?? i.card_id ?? i.item_card_id ?? String(idx),
name: name,
count: 0
})
}
// If the API returns a 'count' or 'remaining', use it. Otherwise assume 1.
const inc = (i.count !== undefined && i.count !== null) ? Number(i.count) : ((i.remaining !== undefined && i.remaining !== null) ? Number(i.remaining) : 1)
groupedMap.get(name).count += inc
})
propCards.value = Array.from(groupedMap.values()).map(item => ({
id: item.id,
name: item.name, // PaymentPopup will handle the " (xN)" display if we pass it correctly.
// Wait, PaymentPopup.vue expects 'name' to be the display string?
// Let's check PaymentPopup.vue again.
// It shows {{ selectedCoupon.name }} (-¥...).
// For cards: {{ selectedCard.name }}.
// So I should format the name here OR update PaymentPopup to show count.
// The plan said "Update PaymentPopup text if needed (e.g. show count)".
// I will format it here for consistency if PaymentPopup is generic,
// BUT updating PaymentPopup to show "Name (xCount)" is cleaner.
// For now, I'll pass 'count' property.
count: item.count
}))
} catch (e) {
propCards.value = []
}
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
syncResumeGame(id)
fetchDetail(id)
fetchIssues(id)
// 获取次数卡
fetchGamePasses()
}
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;
transition: all 0.2s;
&:active {
transform: scale(0.92);
opacity: 0.8;
}
}
.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-container {
padding: 0 $spacing-lg;
margin-bottom: $spacing-xl;
}
.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: 20rpx 24rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.float-left {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.float-badges {
display: flex;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
}
.float-price {
display: flex;
align-items: baseline;
color: $text-main;
font-weight: 800;
min-width: 0;
flex-shrink: 0;
}
.float-price .currency { font-size: 24rpx; margin-right: 2rpx; color: $brand-primary; }
.float-price .amount { font-size: 36rpx; font-weight: 900; font-family: 'DIN Alternate', sans-serif; color: $brand-primary; }
.float-price .unit { font-size: 20rpx; color: $text-sub; margin-left: 2rpx; 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: 72rpx;
line-height: 72rpx;
padding: 0 32rpx;
border-radius: 999rpx;
font-size: 26rpx;
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;
flex-shrink: 0;
white-space: nowrap;
&.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);
}
/* ============= 全屏游戏样式 ============= */
.game-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.game-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(16rpx);
animation: fadeIn 0.3s ease-out;
}
.game-fullscreen {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
animation: scaleIn 0.3s ease-out;
}
.game-topbar {
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx 32rpx;
padding-top: calc(40rpx + env(safe-area-inset-top));
position: relative;
}
.game-topbar-title {
font-size: 36rpx;
font-weight: 800;
color: #fff;
letter-spacing: 2rpx;
}
.game-close-btn {
position: absolute;
right: 32rpx;
top: calc(40rpx + env(safe-area-inset-top));
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
text {
font-size: 48rpx;
color: rgba(255, 255, 255, 0.8);
line-height: 1;
}
&:active {
background: rgba(255, 255, 255, 0.2);
}
}
.game-stats {
display: flex;
justify-content: center;
align-items: center;
gap: 32rpx;
padding: 24rpx 32rpx;
margin: 0 32rpx;
}
.game-btn-draw {
flex-shrink: 0;
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
border: 3rpx solid rgba(255, 255, 255, 0.3);
color: #FF6B6B;
font-size: 28rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.3);
transition: all 0.3s;
&:active {
transform: scale(0.9);
background: rgba(255, 255, 255, 0.25);
}
&[disabled] {
opacity: 0.3;
transform: none;
}
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
.stat-value {
font-size: 44rpx;
font-weight: 900;
color: $accent-gold;
font-family: 'DIN Alternate', sans-serif;
&.countdown {
color: #FF6B6B;
animation: pulse 1s ease-in-out infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
.game-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 32rpx;
}
.game-loading,
.game-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.loading-icon,
.error-icon {
font-size: 80rpx;
}
.loading-text,
.error-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.7);
}
.game-board {
width: 100%;
max-width: 700rpx;
padding: 32rpx;
}
.match-grid-fullscreen {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.match-cell-large {
position: relative;
aspect-ratio: 1 / 1;
border-radius: 20rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
border: 2rpx solid rgba(255, 255, 255, 0.15);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.empty {
background-color: rgba(255, 255, 255, 0.03);
background-image: url('/static/logo.png');
background-size: 50%;
background-position: center;
background-repeat: no-repeat;
border-style: dashed;
border-color: rgba(255, 255, 255, 0.1);
}
&.chosen {
border-color: $brand-primary;
box-shadow: 0 0 24rpx rgba($brand-primary, 0.5);
transform: scale(1.02);
}
&.picked {
border-color: $accent-gold;
box-shadow: 0 0 24rpx rgba($accent-gold, 0.5);
}
&:active {
transform: scale(0.95);
}
}
.match-cell-img-large {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.05);
}
.match-cell-logo {
width: 60%;
height: 60%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0.3;
pointer-events: none;
z-index: 1;
}
.match-cell-large.empty .match-cell-img-large {
background: transparent;
}
.match-cell-type-large {
position: absolute;
left: 12rpx;
bottom: 12rpx;
max-width: 85%;
font-size: 24rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.6);
padding: 6rpx 14rpx;
border-radius: 12rpx;
@include text-ellipsis(1);
}
.game-actions {
display: flex;
justify-content: center;
padding: 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.game-btn {
width: 400rpx;
height: 96rpx;
border-radius: 48rpx;
font-size: 32rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
border: none;
transition: all 0.3s;
color: #fff;
&.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4);
}
&.btn-secondary {
background: rgba(255, 255, 255, 0.15);
border: 2rpx solid rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10rpx);
}
&:active {
transform: scale(0.95);
}
&[disabled] {
opacity: 0.4;
}
}
</style>
<style lang="scss" scoped>
/* 浮动操作栏扩展 - 充值按钮 & Badge */
.game-pass-badge {
display: flex;
align-items: center;
background: rgba(16, 185, 129, 0.15);
padding: 8rpx 14rpx;
border-radius: 30rpx;
border: 1rpx solid rgba(16, 185, 129, 0.3);
flex-shrink: 0;
white-space: nowrap;
.badge-icon {
font-size: 24rpx;
margin-right: 4rpx;
}
.badge-text {
font-size: 22rpx;
color: #10B981;
font-weight: 600;
}
&:active {
opacity: 0.8;
}
}
.game-pass-buy-btn {
background: linear-gradient(90deg, #FF9F43, #FF6B00);
color: #fff;
font-size: 20rpx;
padding: 8rpx 14rpx;
border-radius: 24rpx;
font-weight: 600;
box-shadow: 0 4rpx 8rpx rgba(255, 107, 0, 0.2);
flex-shrink: 0;
white-space: nowrap;
&:active {
transform: scale(0.95);
}
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
</style>