无限赏更新UI

This commit is contained in:
tsui110 2025-12-21 14:38:42 +08:00
parent 9f7c98ddad
commit 2571d4a698
5 changed files with 1288 additions and 157 deletions

View File

@ -78,10 +78,6 @@ export function drawActivityIssue(activity_id, issue_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw`, method: 'POST' })
}
export function getActivityWinRecords(activity_id, page = 1, page_size = 20) {
return authRequest({ url: `/api/app/activities/${activity_id}/wins`, method: 'GET', data: { page, page_size } })
}
export function getIssueChoices(activity_id, issue_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/choices`, method: 'GET' })
}
@ -195,13 +191,6 @@ export function playMatchingGame(game_id) {
return authRequest({ url: '/api/app/matching/play', method: 'POST', data: { game_id } })
}
/**
* 获取游戏状态 (用于重连)
* @param {string} game_id - 游戏ID
*/
export function getMatchingGameState(game_id) {
return authRequest({ url: '/api/app/matching/state', method: 'GET', data: { game_id } })
}
/**
* 获取所有启用的卡牌配置
*/
@ -216,3 +205,15 @@ export function createMatchingPreorder({ issue_id, position, coupon_id = 0, item
data: { issue_id, position, coupon_id, item_card_id }
})
}
export function checkMatchingGame(game_id, total_pairs) {
if (game_id && typeof game_id === 'object') {
total_pairs = game_id.total_pairs
game_id = game_id.game_id
}
return authRequest({
url: '/api/app/matching/check',
method: 'POST',
data: { game_id, total_pairs }
})
}

View File

@ -116,14 +116,17 @@
<view style="height: 180rpx;"></view>
</scroll-view>
<view class="float-bar">
<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 class="action-btn primary" @tap="onParticipate">
<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>
@ -131,6 +134,49 @@
</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>
@ -168,7 +214,7 @@
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PaymentPopup from '../../../components/PaymentPopup.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getActivityWinRecords, getUserCoupons, getItemCards, createWechatOrder, getLotteryResult, getMatchingCardTypes, createMatchingPreorder } from '../../../api/appUser'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, joinLottery, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame } from '../../../api/appUser'
const detail = ref({})
const statusText = ref('')
@ -190,6 +236,91 @@ 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(() => {
@ -252,6 +383,99 @@ function cleanUrl(u) {
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()
@ -288,16 +512,6 @@ function normalizeRewards(list) {
enriched.sort((a, b) => (b.percent - a.percent))
return enriched
}
function normalizeWinRecords(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? i.record_id ?? i.product_id ?? String(idx),
title: i.title ?? i.name ?? i.product_name ?? '',
image: cleanUrl(i.image ?? i.img ?? i.pic ?? i.product_image ?? ''),
count: Number(i.count ?? i.total ?? i.qty ?? 1) || 1,
percent: i.percent !== undefined ? Math.round(Number(i.percent) * 10) / 10 : undefined
}))
}
function isFresh(ts) {
const now = Date.now()
const v = Number(ts || 0)
@ -343,15 +557,6 @@ async function fetchIssues(id) {
await fetchRewardsForIssues(id)
}
async function fetchWinRecords(activityId) {
try {
const data = await getActivityWinRecords(activityId, 1, 50)
winRecords.value = normalizeWinRecords(data)
} catch (e) {
winRecords.value = []
}
}
function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
@ -371,6 +576,7 @@ function setSelectedById(id) {
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
syncResumeGame(activityId.value)
}
function prevIssue() {
@ -379,6 +585,7 @@ function prevIssue() {
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 || []
@ -386,6 +593,7 @@ function nextIssue() {
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) {
@ -406,6 +614,345 @@ function onPreviewBanner() {
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 || ''
@ -424,6 +971,12 @@ async function onParticipate() {
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' })
@ -438,6 +991,20 @@ async function onParticipate() {
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) : ''
}
@ -470,7 +1037,7 @@ async function doDraw() {
const openid = uni.getStorageSync('openid')
if (!openid) { uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' }); return }
uni.showLoading({ title: '处理中...' })
uni.showLoading({ title: '拉起支付...' })
try {
if (!selectedCardType.value) {
uni.hideLoading()
@ -478,14 +1045,15 @@ async function doDraw() {
return
}
const preRes = await createMatchingPreorder({
const joinRes = await joinLottery({
activity_id: Number(aid),
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
channel: 'miniapp',
count: 1,
coupon_id: selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0
})
if (!preRes) throw new Error('下单失败')
const orderNo = preRes.order_no || preRes.data?.order_no || preRes.result?.order_no || preRes.orderNo
if (!joinRes) throw new Error('下单失败')
const orderNo = joinRes.order_no || joinRes.data?.order_no || joinRes.result?.order_no || joinRes.orderNo
if (!orderNo) throw new Error('未获取到订单号')
const payRes = await createWechatOrder({ openid, order_no: orderNo })
@ -502,20 +1070,31 @@ async function doDraw() {
})
})
const resultRes = await getLotteryResult(orderNo)
const raw = resultRes?.list || resultRes?.items || resultRes?.data || resultRes?.result || (Array.isArray(resultRes) ? resultRes : [resultRes])
const first = Array.isArray(raw) ? raw[0] : raw
const name = String((first && (first.title || first.name || first.product_name)) || '未知奖励')
const img = String((first && (first.image || first.img || first.pic || first.product_image)) || '')
uni.showLoading({ title: '创建游戏...' })
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('创建游戏失败')
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 || [])
if (gameId) {
writeMatchingGameCacheEntry(aid, iid, {
game_id: String(gameId),
position: String(selectedCardType.value.code || ''),
all_cards: allCards,
ts: Date.now()
})
}
uni.hideLoading()
uni.showModal({
title: '抽选结果',
content: '恭喜获得:' + name,
showCancel: false,
success: () => { if (img) uni.previewImage({ urls: [img], current: img }) }
title: '支付成功',
content: '已创建对对碰游戏,可点击“继续游戏”继续。',
showCancel: false
})
fetchWinRecords(aid)
} catch (e) {
uni.hideLoading()
if (e?.errMsg && String(e.errMsg).includes('cancel')) {
@ -571,9 +1150,9 @@ onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
syncResumeGame(id)
fetchDetail(id)
fetchIssues(id)
fetchWinRecords(id)
}
fetchCardTypes()
})
@ -1185,6 +1764,53 @@ onLoad((opts) => {
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;
@ -1360,6 +1986,14 @@ onLoad((opts) => {
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); }
}
@ -1376,6 +2010,39 @@ onLoad((opts) => {
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;

View File

@ -1,42 +1,80 @@
<template>
<view class="bg-decoration"></view>
<scroll-view class="page" scroll-y>
<!-- 顶部 Banner -->
<view class="banner" v-if="detail.banner">
<image class="banner-img" :src="detail.banner" mode="widthFix" />
<view class="page-wrapper">
<view class="bg-decoration">
<view class="orb orb-1"></view>
<view class="orb orb-2"></view>
</view>
<!-- 商品信息卡片 -->
<view class="product-card">
<view class="product-info">
<image v-if="detail.banner" class="product-thumb" :src="detail.banner" mode="aspectFill" />
<view class="product-detail">
<view class="product-name">{{ detail.name || detail.title || '无限赏活动' }}</view>
<view class="product-price">¥{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</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">
<text class="price-symbol">¥</text>
<text class="price-num">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text>
<text class="price-unit">/</text>
</view>
<view class="header-tags">
<view class="tag-item">公开透明</view>
<view class="tag-item">随机掉落</view>
</view>
</view>
<view class="product-actions">
<view class="action-btn">📦 盒柜</view>
<view class="header-actions">
<view class="action-btn" @tap="showRules">
<text class="icon">📋</text>
<text>规则</text>
</view>
<view class="action-btn" @tap="goCabinet">
<text class="icon">📦</text>
<text>盒柜</text>
</view>
</view>
</view>
</view>
<!-- 期号切换条 -->
<view class="issue-bar" v-if="showIssues && issues.length">
<button class="nav-btn" @click="prevIssue"></button>
<view class="issue-info">
<text class="issue-label">{{ currentIssueTitle }}</text>
<view class="section-container animate-enter stagger-1" v-if="currentIssueRewards.length > 0">
<view class="section-header">
<text class="section-title">奖池一览</text>
<text class="section-more" @tap="openRewardsPopup">查看全部</text>
</view>
<scroll-view 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>
<button class="nav-btn" @click="nextIssue"></button>
</view>
<!-- 玩法福利标签 -->
<view class="gameplay-tags">
<view class="tag tag-pool">聚宝盆</view>
<view class="tag tag-drop">随机掉落 10%</view>
<view class="tag tag-free">随机免单 10%</view>
</view>
</scroll-view>
<view class="section-container selector-container animate-enter stagger-2">
<view class="issue-header" v-if="showIssues && issues.length">
<view class="issue-switch-btn" @click="prevIssue">
<text class="arrow"></text>
</view>
<view class="issue-info-center">
<text class="issue-current-text">{{ currentIssueTitle }}</text>
<text class="issue-status-badge">{{ statusText || '进行中' }}</text>
</view>
<view class="issue-switch-btn" @click="nextIssue">
<text class="arrow"></text>
</view>
</view>
<view class="gameplay-tags">
<view class="tag tag-pool">聚宝盆</view>
<view class="tag tag-drop">随机掉落 10%</view>
<view class="tag tag-free">随机免单 10%</view>
</view>
</view>
<view style="height: 220rpx;"></view>
</scroll-view>
</view>
<!-- 底部多档位抽赏按钮 -->
<view class="bottom-actions">
@ -57,7 +95,30 @@
<text class="tier-label">抽10发</text>
</button>
</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 rewardsForPopup" :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="!rewardsForPopup.length" class="rewards-empty">暂无奖池数据</view>
</scroll-view>
</view>
</view>
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
<view class="flip-mask" @tap="closeFlip"></view>
<view class="flip-content" @tap.stop>
@ -84,6 +145,7 @@ import { getActivityDetail, getActivityIssues, getActivityIssueRewards, joinLott
const detail = ref({})
const statusText = ref('')
const rewardsVisible = ref(false)
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
@ -92,6 +154,7 @@ const showIssues = computed(() => (detail.value && detail.value.status !== 2))
const activityId = ref('')
const drawLoading = ref(false)
const currentIssueRewards = computed(() => (currentIssueId.value && rewardsMap.value[currentIssueId.value]) ? rewardsMap.value[currentIssueId.value] : [])
const coverUrl = computed(() => cleanUrl(detail.value && (detail.value.image || detail.value.banner || '')))
const currentIssueTitle = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
@ -101,6 +164,10 @@ const currentIssueTitle = computed(() => {
const points = ref(0)
const flipRef = ref(null)
const showFlip = ref(false)
const rewardsForPopup = computed(() => {
const arr = currentIssueRewards.value || []
return Array.isArray(arr) ? arr : []
})
const paymentVisible = ref(false)
const paymentAmount = ref('0.00')
const coupons = ref([])
@ -117,10 +184,37 @@ function statusToText(s) {
return String(s || '')
}
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() {
uni.showModal({
title: '活动规则',
content: detail.value.rules || '1. 选择档位进行抽赏\n2. 每次抽赏随机获得奖品\n3. 奖池与概率以页面展示为准',
showCancel: false
})
}
function goCabinet() {
uni.navigateTo({ url: '/pages/cabinet/index' })
}
async function fetchDetail(id) {
const data = await getActivityDetail(id)
detail.value = data || {}
statusText.value = statusToText(detail.value.status)
const title = String(detail.value.name || detail.value.title || '无限赏')
try { uni.setNavigationBarTitle({ title }) } catch (_) {}
}
function unwrap(list) {
@ -412,50 +506,45 @@ function closeFlip() { showFlip.value = false }
<style lang="scss" scoped>
/* 柯大鸭潮玩 - 无限赏活动页面 */
.page {
min-height: 100vh;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
background: transparent;
.page-wrapper {
min-height: 100vh;
background: $bg-page;
position: relative;
z-index: 1;
overflow: hidden;
}
.bg-decoration {
position: fixed;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background-color: $bg-page;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
&::before, &::after {
content: '';
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.5;
}
&::before {
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.12), transparent 70%);
top: -200rpx;
left: -200rpx;
animation: float 10s ease-in-out infinite;
}
&::after {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($accent-gold, 0.15), transparent 70%);
bottom: 10%;
right: -100rpx;
animation: float 12s ease-in-out infinite reverse;
}
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
}
.orb-1 {
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.2), transparent 70%);
top: -200rpx;
left: -200rpx;
animation: float 10s ease-in-out infinite;
}
.orb-2 {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($accent-gold, 0.2), transparent 70%);
bottom: 20%;
right: -100rpx;
animation: float 12s ease-in-out infinite reverse;
}
@keyframes float {
@ -463,6 +552,288 @@ function closeFlip() { showFlip.value = false }
50% { transform: translate(30rpx, 50rpx); }
}
.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;
}
.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);
}
.header-actions {
display: flex;
flex-direction: column;
gap: $spacing-lg;
margin-left: 20rpx;
padding-left: $spacing-lg;
border-left: 1rpx solid rgba(0,0,0,0.06);
justify-content: center;
height: 140rpx;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
font-size: $font-xs;
color: $text-sub;
transition: all 0.2s;
&:active {
transform: scale(0.9);
color: $text-main;
}
}
.action-btn .icon {
font-size: $font-xl;
margin-bottom: 6rpx;
filter: grayscale(0.2);
}
.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-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding: 0 4rpx;
}
.section-title {
font-size: $font-lg;
font-weight: 800;
color: $text-main;
position: relative;
padding-left: $spacing-lg;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8rpx;
height: 28rpx;
background: $gradient-brand;
border-radius: 4rpx;
}
}
.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;
}
}
.preview-scroll {
white-space: nowrap;
margin: 0 -$spacing-lg;
padding: 0 $spacing-lg;
width: calc(100% + 40rpx);
}
.preview-item {
display: inline-block;
width: 200rpx;
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: 200rpx;
height: 200rpx;
border-radius: $radius-lg;
background: $bg-secondary;
margin-bottom: $spacing-md;
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);
}
.selector-container {
display: flex;
flex-direction: column;
background: rgba($bg-card, 0.95);
backdrop-filter: blur(20rpx);
}
.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;
font-weight: 700;
}
.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; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40rpx); }
to { opacity: 1; transform: translateY(0); }
}
.banner {
padding: $spacing-lg $spacing-lg 0;
animation: fadeInDown 0.6s $ease-out;
@ -523,21 +894,6 @@ function closeFlip() { showFlip.value = false }
flex-direction: column;
gap: $spacing-sm;
}
.action-btn {
background: rgba($brand-primary, 0.05);
border: 1rpx solid rgba($brand-primary, 0.2);
border-radius: $radius-sm;
padding: $spacing-sm $spacing-lg;
font-size: $font-sm;
color: $brand-primary-dark;
text-align: center;
font-weight: 600;
transition: all $transition-fast;
}
.action-btn:active {
background: rgba($brand-primary, 0.1);
transform: scale(0.95);
}
/* 期号切换条 */
.issue-bar {
@ -709,6 +1065,100 @@ function closeFlip() { showFlip.value = false }
color: #FFFFFF;
}
.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: fadeInUp 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;
}
/* 翻牌弹窗 */
.flip-overlay {
position: fixed;

View File

@ -142,7 +142,7 @@ import { ref, computed } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import FlipGrid from '../../../components/FlipGrid.vue'
import YifanSelector from '@/components/YifanSelector.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getActivityWinRecords } from '../../../api/appUser'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards } from '../../../api/appUser'
const detail = ref({})
const issues = ref([])
@ -336,16 +336,6 @@ function normalizeRewards(list) {
enriched.sort((a, b) => (b.percent - a.percent))
return enriched
}
function normalizeWinRecords(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? i.record_id ?? i.product_id ?? String(idx),
title: i.title ?? i.name ?? i.product_name ?? '',
image: cleanUrl(i.image ?? i.img ?? i.pic ?? i.product_image ?? ''),
count: Number(i.count ?? i.total ?? i.qty ?? 1) || 1,
percent: i.percent !== undefined ? Math.round(Number(i.percent) * 10) / 10 : undefined
}))
}
function isFresh(ts) {
const now = Date.now()
const v = Number(ts || 0)
@ -391,15 +381,6 @@ async function fetchIssues(id) {
await fetchRewardsForIssues(id)
}
async function fetchWinRecords(activityId) {
try {
const data = await getActivityWinRecords(activityId, 1, 50)
winRecords.value = normalizeWinRecords(data)
} catch (e) {
winRecords.value = []
}
}
function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
@ -501,7 +482,6 @@ onLoad((opts) => {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
fetchWinRecords(id)
}
})

View File

@ -131,7 +131,7 @@
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getOrderDetail, cancelOrder } from '../../api/appUser'
import { getOrderDetail, cancelOrder, createWechatOrder } from '../../api/appUser'
const orderId = ref('')
const order = ref(null)
@ -186,7 +186,40 @@ function handleCancel() {
}
function handlePay() {
uni.showToast({ title: '支付功能开发中', icon: 'none' })
const openid = uni.getStorageSync('openid')
if (!openid) {
uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' })
return
}
const ord = order.value
if (!ord || !ord.order_no) return
uni.showLoading({ title: '拉起支付...' })
createWechatOrder({ openid, order_no: ord.order_no })
.then((payRes) => new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
}))
.then(async () => {
uni.hideLoading()
uni.showToast({ title: '支付成功', icon: 'success' })
await loadOrder()
})
.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' })
})
}
function copyText(text) {