From 49d5345743b67593d7ac3b97cc6add737c058b97 Mon Sep 17 00:00:00 2001
From: hzj <1304805162@qq.com>
Date: Sat, 23 May 2026 17:25:18 +0800
Subject: [PATCH] =?UTF-8?q?feat(=E5=8F=A4=E5=B0=94=E9=82=A6=E8=8A=82):=20?=
=?UTF-8?q?=E5=AF=B9=E6=8E=A5=E6=8E=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/directives/smartImage.js | 51 +-
src/views/Activities/GulbenFestival/index.vue | 738 ++++++++++++++++--
.../Activities/GulbenFestival/page.config.js | 2 +
.../Activities/GulbenFestival/页面创建信息.md | 42 +-
4 files changed, 742 insertions(+), 91 deletions(-)
diff --git a/src/directives/smartImage.js b/src/directives/smartImage.js
index 9eea0e0..103aeae 100644
--- a/src/directives/smartImage.js
+++ b/src/directives/smartImage.js
@@ -1,9 +1,16 @@
import { nextTick } from 'vue'
import { imageCacheManager } from '@/utils/image/imageCacheManager'
import { isLowEndDevice } from '@/utils/performanceMode.js'
-import { resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
+import { isProtectedAssetUrl, resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
+
+const TRANSPARENT_PLACEHOLDER =
+ 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='
export const smartImage = {
+ beforeMount(el) {
+ preventNativeBrokenImage(el)
+ },
+
async mounted(el, binding) {
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade' || isLowEndDevice()
const keepVisible = binding.modifiers.noFade || binding.arg === 'noFade'
@@ -35,6 +42,7 @@ export const smartImage = {
}
if (currentUrl && currentUrl !== el._smartImageCurrentUrl) {
+ preventNativeBrokenImage(el)
prepareImageLoadingState(el, skipFade, keepVisible)
await processImage(el, currentUrl)
el._smartImageCurrentUrl = currentUrl
@@ -47,21 +55,25 @@ export const smartImage = {
el.removeEventListener('error', el._smartImageErrorHandler)
delete el._smartImageCurrentUrl
delete el._smartImageOriginalBindingValue
+ delete el._smartImageOriginalSrcBeforePlaceholder
+ delete el._smartImageRequestToken
delete el._smartImageLoadHandler
delete el._smartImageErrorHandler
},
}
function getValidUrl(bindingValue, el) {
- const candidates = [bindingValue, el.getAttribute('src'), el.src]
+ const candidates = [bindingValue, el._smartImageOriginalSrcBeforePlaceholder, el.getAttribute('src'), el.src]
for (const candidate of candidates) {
if (
+ typeof candidate === 'string' &&
candidate &&
candidate !== 'null' &&
candidate !== 'undefined' &&
+ candidate !== TRANSPARENT_PLACEHOLDER &&
!candidate.includes('blob:') &&
- typeof candidate === 'string'
+ !candidate.includes('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw')
) {
return candidate
}
@@ -70,6 +82,18 @@ function getValidUrl(bindingValue, el) {
return null
}
+function preventNativeBrokenImage(el) {
+ const rawSrc = el.getAttribute('src') || ''
+ if (!rawSrc || rawSrc === TRANSPARENT_PLACEHOLDER) {
+ return
+ }
+
+ if (isProtectedAssetUrl(rawSrc)) {
+ el._smartImageOriginalSrcBeforePlaceholder = rawSrc
+ el.src = TRANSPARENT_PLACEHOLDER
+ }
+}
+
function prepareImageLoadingState(el, skipFade, keepVisible = false) {
if (keepVisible) {
el.style.opacity = '1'
@@ -94,8 +118,15 @@ async function processImage(el, url) {
try {
const resolvedUrl = resolveProtectedAssetUrl(url)
+ const requestToken = Symbol('smart-image-request')
+ el._smartImageRequestToken = requestToken
+ preventNativeBrokenImage(el)
const imageUrl = await imageCacheManager.scheduleRequest(resolvedUrl)
+ if (el._smartImageRequestToken !== requestToken) {
+ return
+ }
+
const handleLoad = () => {
showImage(el)
@@ -120,7 +151,19 @@ async function processImage(el, url) {
} catch (error) {
const fallbackUrl = resolveProtectedAssetUrl(url)
console.warn(`Image processing failed: ${fallbackUrl}`, error)
- el.src = fallbackUrl
+ const imageLoaded = await loadNativeImage(fallbackUrl)
+ if (imageLoaded) {
+ el.src = fallbackUrl
+ }
showImage(el)
}
}
+
+function loadNativeImage(url) {
+ return new Promise((resolve) => {
+ const img = new Image()
+ img.onload = () => resolve(true)
+ img.onerror = () => resolve(false)
+ img.src = url
+ })
+}
diff --git a/src/views/Activities/GulbenFestival/index.vue b/src/views/Activities/GulbenFestival/index.vue
index 96d619b..94b658e 100644
--- a/src/views/Activities/GulbenFestival/index.vue
+++ b/src/views/Activities/GulbenFestival/index.vue
@@ -32,7 +32,7 @@
@@ -629,13 +630,13 @@
:imgUrl="imageUrl('ticketHandleBtBg')"
:class="{ 'collect-action-disabled': !canCombineActiveCards }"
style="width: 25vw; margin: 0; padding: 0"
- contentStyle="padding: 2vw 3vw; align-items: center; justify-content: center;"
+ contentStyle="padding: 0; align-items: center; justify-content: center;"
@click="handleCardCombine"
>
@@ -1061,7 +1062,7 @@
:valueIconUrl="coinIcon"
:avatar-square="isRoomRankingSection"
style="width: 100%"
- @click="gotoRankingTarget(rankingTop3[0])"
+ @click="handleRankingClick(rankingTop3[0])"
/>
@@ -1074,7 +1075,7 @@
:valueIconUrl="coinIcon"
:avatar-square="isRoomRankingSection"
style="width: 100%"
- @click="gotoRankingTarget(rankingTop3[2])"
+ @click="handleRankingClick(rankingTop3[2])"
/>
@@ -1087,7 +1088,7 @@
:imgUrl="imageUrl('RankingItem')"
:contentStyle="`padding: 0 8vw`"
style="width: 100%; margin: 0 0 5vw; padding: 0"
- @click="gotoRankingTarget(listItem)"
+ @click="handleRankingClick(listItem)"
>
@@ -1120,21 +1121,34 @@
style="display: flex; align-items: center; gap: 1vw; margin-top: 0.5vw"
>
+ contentStyle="padding: 0 0 0 5vw; align-items: center; justify-content: center;"
+ >
+
+ {{ listItem.charmLevel || 0 }}
+
+
+ contentStyle="padding: 0 0 0 4vw; align-items: center; justify-content: center;"
+ >
+
+ {{ listItem.wealthLevel || 0 }}
+
+
@@ -1267,21 +1281,30 @@
+ :imgUrl="imageUrl(getLevelBgImageName('userLevel', myRanking.charmLevel))"
+ style="width: 12vw; margin: 0; padding: 0"
+ contentStyle="padding: 0 0 0 5vw; align-items: center; justify-content: center;"
+ >
+
+ {{ myRanking.charmLevel || 0 }}
+
+
+ :imgUrl="imageUrl(getLevelBgImageName('wealthLevel', myRanking.wealthLevel))"
+ style="width: 12vw; margin: 0; padding: 0"
+ contentStyle="padding: 0 0 0 4vw; align-items: center; justify-content: center;"
+ >
+
+ {{ myRanking.wealthLevel || 0 }}
+
+
@@ -1308,7 +1331,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Remaining Tickets: *{{ cardDrawTicketCount }}
+
+
+ 1000/coins
+
+
+
+
+
+
+ Accept
+
+
+
+
+ Combine * 1
+
+
+
+
+
+
+ Confirm
+
+
+
+
+
+
+
+
![]()
+
@@ -1373,13 +1656,15 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { setDocumentDirection } from '@/locales/i18n'
import { useLangStore } from '@/stores/lang'
-import { getPngUrl } from '@/config/imagePaths.js'
+import { getPngUrl, getWebpUrl } from '@/config/imagePaths.js'
import { preloadImages, preloadImagesIdle } from '@/utils/image/imagePreloader.js'
import { showError } from '@/utils/toast.js'
-import { gotoRoom, viewUserInfo } from '@/utils/appBridge.js'
+import { viewUserInfo } from '@/utils/appBridge.js'
import { connectToApp } from '@/utils/appConnector.js'
import { handleRewardImageError } from '@/utils/image/imageHandler.js'
import { formatRewardDisplay } from '@/utils/rewardFormatter.js'
+import { formatUTCCustom } from '@/utils/utcFormat.js'
+import { useDebounce } from '@/utils/useDebounce.js'
import {
apiGetCardRewardMarquee,
apiGetCardRewardPrizeList,
@@ -1389,6 +1674,7 @@ import {
apiPostCardDraw,
apiPostCardRedeem,
apiPostCardRewardDraw,
+ apiPostCardRewardRecordList,
getRankingListAndMyRanking,
getThisWeekRewardsAndGifts,
} from '@/api/activity.js'
@@ -1408,8 +1694,8 @@ const gulbenActivityId = '1001'
const templateId = ref(pageConfig.activity.templateId || '2057676469390872578')
const activityType = ref(pageConfig.activity.activityType || '')
const roomRankingParams = {
- startDate: '20260518',
- endDate: '20260524',
+ startDate: '20260523',
+ endDate: '20260606',
size: 20,
}
@@ -1424,6 +1710,12 @@ const isCardRedeeming = ref(false)
const activeLotteryPoolIndex = ref(0)
const activeCollectGameIndex = ref(0)
const ruleShow = ref(false)
+const historyShow = ref(false)
+const cardDrawResultShow = ref(false)
+const cardDrawResultImageUrl = ref('')
+const cardResultActionMode = ref('draw')
+const cardDrawAnimationShow = ref(false)
+const cardDrawAnimationUrl = ref('')
const activityCode = ref(pageConfig.activity.activityCode || '')
const activity = ref({})
const Days = ref(0)
@@ -1451,7 +1743,14 @@ const loadedRankingType = ref('')
const rankingLoading = ref(false)
const rankingHasMore = ref(true)
const rankingLoadmore = ref(null)
+const historyRecords = ref([])
+const historyPage = ref(1)
+const historySize = 10
+const historyLoading = ref(false)
+const historyHasMore = ref(true)
+const historyLoadmore = ref(null)
const personalRewardLoaded = ref(false)
+const kingQueenEffectiveData = ref(null)
const activityRewards = ref([])
const cardTicketList = ref([])
const cardRewardPrizePools = ref({})
@@ -1493,6 +1792,9 @@ const taskList = ref([
let countdownTimer = null
let lotteryAnimationTimer = null
let rankingObserver = null
+let historyObserver = null
+let cardDrawAnimationTimer = null
+let kingQueenEffectiveRequest = null
const createLotteryPrizes = (poolName, count) =>
Array.from({ length: count }, (_, index) => ({
@@ -1585,6 +1887,13 @@ const cardTemplateImageMap = {
2022: 'redeemSSR2022',
}
+const cardTemplateAnimationMap = Object.fromEntries(
+ Object.entries(cardTemplateImageMap).map(([cardTemplateId, imageName]) => [
+ Number(cardTemplateId),
+ imageName.replace('redeem', 'anima'),
+ ]),
+)
+
// 多语言方向:当前只根据语言类型判断是否需要 RTL 布局。
const currentLangType = computed(() => {
return langStore.selectedLang?.type || 'en'
@@ -1593,6 +1902,7 @@ const currentLangType = computed(() => {
const isRtlLayout = computed(() => currentLangType.value === 'ar')
const imageUrl = (filename) => getPngUrl(pageConfig.assetPath, filename)
+const webpUrl = (filename) => getWebpUrl(pageConfig.assetPath, filename)
// 多语言图片:阿语图片存在时按 `_ar` 后缀读取,否则由资源层回退原图。
const getImgName = (filename) => {
@@ -1799,7 +2109,9 @@ const canRedeemActiveCards = computed(() => {
const cardDrawTicketCount = computed(() => getTicketRemainingCount('CARD'))
-const maskLayerShow = computed(() => ruleShow.value)
+const maskLayerShow = computed(
+ () => ruleShow.value || historyShow.value || cardDrawResultShow.value,
+)
const activeRankingLoadKey = computed(() => {
if (isRoomRankingSection.value) {
@@ -2026,8 +2338,106 @@ const refreshCardBagGrades = async (cardGrades) => {
await Promise.all(uniqueGrades.map((cardGrade) => getCardBagListByGrade(cardGrade)))
}
+const getCardDrawResultItem = (body) => {
+ if (Array.isArray(body)) {
+ return body[0] || null
+ }
+
+ if (!body || typeof body !== 'object') {
+ return null
+ }
+
+ const candidateLists = [body.cards, body.records, body.results, body.rewards]
+ const listItem = candidateLists.find((list) => Array.isArray(list) && list.length)?.[0]
+
+ return listItem || body.card || body.cardInfo || body
+}
+
+const getCardTemplateId = (item = {}) => {
+ return (
+ item.cardTemplateId ||
+ item.templateId ||
+ item.cardId ||
+ item.prizeId ||
+ item.card?.cardTemplateId ||
+ item.card?.templateId ||
+ item.prize?.cardTemplateId ||
+ item.prize?.templateId ||
+ item.prize?.prizeId ||
+ ''
+ )
+}
+
+const getCardTemplateLocalImageUrl = (item = {}) => {
+ const cardTemplateId = getCardTemplateId(item)
+ const imageName = cardTemplateImageMap[Number(cardTemplateId)]
+
+ return imageName ? imageUrl(imageName) : ''
+}
+
+const getCardDrawAnimationUrl = (body) => {
+ const resultItem = getCardDrawResultItem(body)
+ const animationName = cardTemplateAnimationMap[Number(getCardTemplateId(resultItem || {}))]
+
+ return animationName ? webpUrl(animationName) : ''
+}
+
+const getCardDrawResultImageUrl = (body) => {
+ const resultItem = getCardDrawResultItem(body)
+ if (!resultItem) {
+ return imageUrl('cardBackBg')
+ }
+
+ const localImage = getCardTemplateLocalImageUrl(resultItem)
+ if (localImage) {
+ return localImage
+ }
+
+ return (
+ resultItem.cardImage ||
+ resultItem.prizeImage ||
+ resultItem.image ||
+ resultItem.cover ||
+ resultItem.card?.cardImage ||
+ resultItem.card?.image ||
+ imageUrl('cardBackBg')
+ )
+}
+
+const getLotteryRewardResultItem = (drawResult = {}) => {
+ const rewards = Array.isArray(drawResult?.results) ? drawResult.results : []
+ return rewards.find((reward) => reward?.isWin) || rewards[0] || null
+}
+
+const getLotteryRewardResultImageUrl = (drawResult = {}) => {
+ const resultItem = getLotteryRewardResultItem(drawResult)
+ return resultItem?.prizeImage || resultItem?.prize?.prizeImage || ''
+}
+
+const showCardDrawAnimationThenResult = (animationUrl, actionMode = 'draw') => {
+ if (cardDrawAnimationTimer) {
+ window.clearTimeout(cardDrawAnimationTimer)
+ }
+
+ cardResultActionMode.value = actionMode
+ cardDrawAnimationUrl.value = animationUrl
+ cardDrawAnimationShow.value = Boolean(animationUrl)
+ cardDrawResultShow.value = false
+
+ if (!animationUrl) {
+ cardDrawResultShow.value = true
+ return
+ }
+
+ cardDrawAnimationTimer = window.setTimeout(() => {
+ cardDrawAnimationShow.value = false
+ cardDrawAnimationTimer = null
+ cardDrawResultShow.value = true
+ }, 1500)
+}
+
const handleCardDraw = async (drawCount = 1) => {
- if (isCardDrawing.value) {
+ if (isCardDrawing.value || cardDrawAnimationShow.value) {
return
}
@@ -2041,6 +2451,8 @@ const handleCardDraw = async (drawCount = 1) => {
})
if (resDraw.status) {
+ cardDrawResultImageUrl.value = getCardDrawResultImageUrl(resDraw.body)
+ showCardDrawAnimationThenResult(getCardDrawAnimationUrl(resDraw.body), 'draw')
await Promise.all([getCardTicketList(), getCardBagListByGrade('R')])
}
} catch (error) {
@@ -2063,7 +2475,7 @@ const getCombineTargetGrade = () => {
}
const handleCardCombine = async () => {
- if (isCardCombining.value || !canCombineActiveCards.value) {
+ if (isCardCombining.value || cardDrawAnimationShow.value || !canCombineActiveCards.value) {
return
}
@@ -2076,6 +2488,8 @@ const handleCardCombine = async () => {
})
if (resCombine.status) {
+ cardDrawResultImageUrl.value = getCardDrawResultImageUrl(resCombine.body)
+ showCardDrawAnimationThenResult(getCardDrawAnimationUrl(resCombine.body), 'combine')
await refreshCardBagGrades([activeCollectGameMode.value.cardGrade, getCombineTargetGrade()])
}
} catch (error) {
@@ -2142,9 +2556,9 @@ const receiveTaskReward = async (code) => {
}
const getWeeklyGifts = async () => {
- const resRewardsAndGifts = await getThisWeekRewardsAndGifts(templateId.value)
- if (resRewardsAndGifts.status && resRewardsAndGifts.body?.gifts?.length) {
- weeklyGifts.value = resRewardsAndGifts.body.gifts.slice(0, 3)
+ const rewardsAndGifts = await getKingQueenEffectiveData()
+ if (rewardsAndGifts.gifts?.length) {
+ weeklyGifts.value = rewardsAndGifts.gifts.slice(0, 3)
}
}
@@ -2212,8 +2626,98 @@ const hasRewardCover = (reward) => {
return Boolean(reward?.cover)
}
+const vipLevelImageMap = {
+ VISCOUNT: 'vip1',
+ EARL: 'vip2',
+ MARQUIS: 'vip3',
+ DUKEI: 'vip4',
+ KING: 'vip5',
+ EMPEROR: 'vip6',
+}
+
+const getVipLevelImageName = (vipLevelName = '') => {
+ return vipLevelImageMap[String(vipLevelName || '').toUpperCase()] || ''
+}
+
+const getLevelBgImageName = (prefix, level) => {
+ const levelValue = Number(level) || 0
+ const imageIndex = Math.min(5, Math.max(1, Math.ceil(levelValue / 10) || 1))
+
+ return `${prefix}${imageIndex}Bg`
+}
+
const getLotteryPrizeDisplayText = (prize) => {
- return prize?.prizeName || ''
+ if (!prize) {
+ return ''
+ }
+
+ if (prize.days !== undefined && prize.days !== null && prize.days !== '') {
+ return `${prize.days} days`
+ }
+
+ return prize.prizeValue ?? ''
+}
+
+const getHistoryRecordImage = (record = {}) => {
+ return (
+ getCardTemplateLocalImageUrl(record) || record?.prize?.prizeImage || record.prizeImage || ''
+ )
+}
+
+const getHistoryRecordPrizeText = (record = {}) => {
+ const days = record?.prize?.days ?? record.days
+ if (days !== undefined && days !== null && days !== '') {
+ return `${days} days`
+ }
+
+ const prizeValue = record?.prize?.prizeValue ?? record.prizeValue
+ return prizeValue !== undefined && prizeValue !== null && prizeValue !== '' ? prizeValue : ''
+}
+
+const formatHistoryRecordTime = (time) => {
+ return time ? formatUTCCustom(time) : ''
+}
+
+const normalizeHistoryRecords = (body) => {
+ if (Array.isArray(body?.records)) {
+ return body.records
+ }
+
+ if (Array.isArray(body)) {
+ return body
+ }
+
+ return []
+}
+
+const getHistoryRecords = async (isReset = false) => {
+ if (historyLoading.value || (!historyHasMore.value && !isReset)) {
+ return
+ }
+
+ historyLoading.value = true
+
+ try {
+ const resRecords = await apiPostCardRewardRecordList({
+ activityId: gulbenActivityId,
+ size: historySize,
+ current: historyPage.value,
+ })
+
+ const body = resRecords.status ? resRecords.body : null
+ const records = normalizeHistoryRecords(body)
+ historyRecords.value = isReset ? records : [...historyRecords.value, ...records]
+
+ const total = Number(body?.total || 0)
+ historyHasMore.value = total
+ ? historyRecords.value.length < total
+ : records.length >= historySize
+ } catch (error) {
+ showError(error.errorMsg)
+ } finally {
+ historyLoading.value = false
+ observeHistoryLoadmore()
+ }
}
const getRewardRenderKey = (reward, index) => {
@@ -2226,18 +2730,34 @@ const getRewardRenderKey = (reward, index) => {
].join('-')
}
+const getKingQueenEffectiveData = async () => {
+ if (kingQueenEffectiveData.value) {
+ return kingQueenEffectiveData.value
+ }
+
+ if (!kingQueenEffectiveRequest) {
+ kingQueenEffectiveRequest = getThisWeekRewardsAndGifts(templateId.value)
+ }
+
+ try {
+ const resRewardsAndGifts = await kingQueenEffectiveRequest
+ kingQueenEffectiveData.value =
+ resRewardsAndGifts.status && resRewardsAndGifts.body ? resRewardsAndGifts.body : {}
+ return kingQueenEffectiveData.value
+ } finally {
+ kingQueenEffectiveRequest = null
+ }
+}
+
const getPersonalRewardConfigs = async () => {
if (personalRewardLoaded.value) {
return
}
- personalRewardLoaded.value = true
-
try {
- const resRewardsAndGifts = await getThisWeekRewardsAndGifts(templateId.value)
- if (resRewardsAndGifts.status && resRewardsAndGifts.body) {
- activityRewards.value = (resRewardsAndGifts.body.butOneRewards || []).slice(0, 3)
- }
+ const rewardsAndGifts = await getKingQueenEffectiveData()
+ activityRewards.value = (rewardsAndGifts.butOneRewards || []).slice(0, 3)
+ personalRewardLoaded.value = true
} catch (error) {
showError(error.errorMsg)
}
@@ -2248,11 +2768,14 @@ const normalizePersonalRankingItem = (item = {}, index = 0) => {
...item,
rank: item.rank || item.ranking || (index === null ? '' : index + 1),
userId: item.userId || item.id || '',
- userAvatar: item.userAvatar || '',
+ userAvatar: item.avatar || item.userAvatar || '',
userName: item.userName || item.userNickname || item.nickname || '',
amount: item.amount ?? item.quantity ?? item.total ?? 0,
account: item.specialAccount || item.account || '',
specialAccount: item.specialAccount || '',
+ wealthLevel: item.wealthLevel ?? item.userProfile?.wealthLevel ?? 0,
+ charmLevel: item.charmLevel ?? item.userProfile?.charmLevel ?? 0,
+ vipLevelName: item.vipLevelName || item.userProfile?.vipLevelName || '',
}
}
@@ -2342,13 +2865,7 @@ const getCurrentRankingList = async () => {
await getPersonalRanking()
}
-const gotoRankingTarget = (rankingItem = {}) => {
- if (isRoomRankingSection.value) {
- console.log('[GulbenFestival] Room Rank click roomId:', rankingItem.roomId, rankingItem)
- gotoRoom(rankingItem.roomId)
- return
- }
-
+const handleRankingClick = (rankingItem = {}) => {
if (isPersonalRankingSection.value) {
console.log('[GulbenFestival] Personal Rank click userId:', rankingItem.userId, rankingItem)
viewUserInfo(rankingItem.userId)
@@ -2395,6 +2912,26 @@ const observeRankingLoadmore = async () => {
rankingObserver.observe(rankingLoadmore.value)
}
+const observeHistoryLoadmore = async () => {
+ await nextTick()
+ if (!historyObserver || !historyLoadmore.value || !historyShow.value || !historyHasMore.value) {
+ return
+ }
+
+ historyObserver.disconnect()
+ historyObserver.observe(historyLoadmore.value)
+}
+
+const loadMoreHistoryRecords = useDebounce(async () => {
+ if (historyLoading.value || !historyHasMore.value) {
+ return
+ }
+
+ console.log('上拉加载')
+ historyPage.value += 1
+ await getHistoryRecords()
+}, 300)
+
const normalizeLotteryIndex = (index) => {
const prizeCount = activeLotteryPrizes.value.length
if (!prizeCount) {
@@ -2404,7 +2941,7 @@ const normalizeLotteryIndex = (index) => {
return (((Number(index) || 0) % prizeCount) + prizeCount) % prizeCount
}
-// 抽奖结果位置计算:优先用接口返回的奖品 id/name 在奖池中查找位置。
+// 抽奖结果位置计算:优先用接口返回的奖品 id 在奖池中查找位置。
const getLotteryResultIndex = (drawResult) => {
const rewards = Array.isArray(drawResult?.results) ? drawResult.results : []
const resultReward = rewards.find((reward) => reward?.isWin) || rewards[0] || drawResult
@@ -2413,8 +2950,6 @@ const getLotteryResultIndex = (drawResult) => {
const matchedPrizeIndex = prizeList.findIndex((prize) => {
const samePrizeId =
resultReward?.prizeId !== undefined && String(prize.id) === String(resultReward.prizeId)
- const samePrizeName =
- resultReward?.prizeName && prize.prizeName && prize.prizeName === resultReward.prizeName
const samePrizeImage =
resultReward?.prizeImage && prize.prizeImage && prize.prizeImage === resultReward.prizeImage
const samePrizeTypeAndValue =
@@ -2423,7 +2958,7 @@ const getLotteryResultIndex = (drawResult) => {
resultReward.prizeType === prize.prizeType &&
Number(resultReward?.prizeValue || 0) === Number(prize?.prizeValue || 0)
- return samePrizeId || samePrizeName || samePrizeImage || samePrizeTypeAndValue
+ return samePrizeId || samePrizeImage || samePrizeTypeAndValue
})
if (matchedPrizeIndex >= 0) {
@@ -2523,8 +3058,12 @@ const handleLotteryDraw = async (drawCount = 1) => {
if (!drawResult) {
return
}
+
const resultIndex = getLotteryResultIndex(drawResult)
await startLotteryAnimation(resultIndex)
+ cardResultActionMode.value = 'rewardDraw'
+ cardDrawResultImageUrl.value = getLotteryRewardResultImageUrl(drawResult)
+ cardDrawResultShow.value = true
} catch (error) {
showError(error.errorMsg)
} finally {
@@ -2597,7 +3136,32 @@ const openRulePopup = () => {
}
const closeRulePopup = () => {
+ closePopup()
+}
+
+const openHistoryPopup = async () => {
+ historyShow.value = true
+ historyPage.value = 1
+ historyHasMore.value = true
+ historyRecords.value = []
+ await getHistoryRecords(true)
+ observeHistoryLoadmore()
+}
+
+const closePopup = () => {
ruleShow.value = false
+ historyShow.value = false
+ cardDrawResultShow.value = false
+ cardDrawAnimationShow.value = false
+
+ if (cardDrawAnimationTimer) {
+ window.clearTimeout(cardDrawAnimationTimer)
+ cardDrawAnimationTimer = null
+ }
+
+ if (historyObserver) {
+ historyObserver.disconnect()
+ }
}
// 图片预加载:支持普通图片名和带 localized 标记的配置项。
@@ -2619,7 +3183,10 @@ const completePreloading = async () => {
console.error('预加载过程中发生错误:', error)
} finally {
isLoading.value = false
- preloadImagesIdle(resolvePreloadUrls(pageConfig.preload.background))
+ preloadImagesIdle([
+ ...resolvePreloadUrls(pageConfig.preload.background),
+ ...Object.values(cardTemplateAnimationMap).map((imageName) => webpUrl(imageName)),
+ ])
observeRankingLoadmore()
}
}
@@ -2685,6 +3252,20 @@ onMounted(() => {
}
})
})
+
+ historyObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.intersectionRatio > 0 && historyShow.value) {
+ loadMoreHistoryRecords()
+ }
+ })
+ },
+ {
+ rootMargin: '80px',
+ threshold: 0,
+ },
+ )
}
connectToAppHandler()
startCountdown()
@@ -2703,6 +3284,14 @@ onUnmounted(() => {
if (rankingObserver) {
rankingObserver.disconnect()
}
+
+ if (historyObserver) {
+ historyObserver.disconnect()
+ }
+
+ if (cardDrawAnimationTimer) {
+ window.clearTimeout(cardDrawAnimationTimer)
+ }
})
@@ -2713,9 +3302,22 @@ onUnmounted(() => {
.fullPage {
width: 100%;
- min-height: 100vh;
+ height: 100vh;
+ overflow-y: auto;
overflow-x: hidden;
background: #0b190d;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.fullPage::-webkit-scrollbar,
+.scroll::-webkit-scrollbar {
+ display: none;
+}
+
+.scroll {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
}
@media screen and (max-width: 360px) {
@@ -2902,12 +3504,14 @@ onUnmounted(() => {
border-radius: 4px;
}
-.rule-popup-scroll {
+.rule-popup-scroll,
+.history-popup-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
-.rule-popup-scroll::-webkit-scrollbar {
+.rule-popup-scroll::-webkit-scrollbar,
+.history-popup-scroll::-webkit-scrollbar {
display: none;
}
diff --git a/src/views/Activities/GulbenFestival/page.config.js b/src/views/Activities/GulbenFestival/page.config.js
index 95e157f..57d870b 100644
--- a/src/views/Activities/GulbenFestival/page.config.js
+++ b/src/views/Activities/GulbenFestival/page.config.js
@@ -49,6 +49,7 @@ export const pageConfig = {
'gameTitleSSR',
'giftBg',
'helpBt',
+ 'historyBg',
'leftBt',
'lotteryPrizeActiveBg',
'lotteryPrizeBg',
@@ -65,6 +66,7 @@ export const pageConfig = {
'redeemSR2013',
'redeemSSR2021',
'redeemSSR2022',
+ 'resultBg',
'RewardTop1',
'RewardTop2',
'RewardTop3',
diff --git a/src/views/Activities/GulbenFestival/页面创建信息.md b/src/views/Activities/GulbenFestival/页面创建信息.md
index 7ebe4d7..fbb32bd 100644
--- a/src/views/Activities/GulbenFestival/页面创建信息.md
+++ b/src/views/Activities/GulbenFestival/页面创建信息.md
@@ -375,7 +375,7 @@
"barrageList": ""
},
"actions": {
- "rewardDraw": "点击 lotteryBg 下层 draw1Bt 调用 apiPostCardRewardDraw({ activityId:'1001', ticketType:当前奖池 poolType, drawCount:1 }),当前奖池票券数量为 0 时按钮置灰且点击无效;点击 draw5BtBg 调用 drawCount:5,当前奖池票券数量不足 5 时按钮置灰且点击无效;接口成功后刷新 apiGetCardTicketList('1001') 同步第一层三种票券数量,并保留当前 lotteryPrizeBg / lotteryPrizeActiveBg 转圈动画",
+ "rewardDraw": "点击 lotteryBg 下层 draw1Bt 调用 apiPostCardRewardDraw({ activityId:'1001', ticketType:当前奖池 poolType, drawCount:1 }),当前奖池票券数量为 0 时按钮置灰且点击无效;点击 draw5BtBg 调用 drawCount:5,当前奖池票券数量不足 5 时按钮置灰且点击无效;接口成功后刷新 apiGetCardTicketList('1001') 同步第一层三种票券数量,先根据接口结果播放 lotteryPrizeActiveBg 模拟转圈动画,动画结束后再展示 resultBg 结果弹窗,底部按钮文本为 Confirm,点击后关闭弹窗",
"drawOnce": "点击 GameDrawBtBg 调用 apiPostCardDraw({ activityId:'1001', drawCount:1, needCoins: cardDrawTicketCount === 0 });接口成功后刷新 apiGetCardTicketList('1001') 和 R 级 apiPostCardBagList({ activityId:'1001', cardGrade:'R', current:1, size:20 })",
"drawFive": "",
"combineCard": "点击 Combine 调用 apiPostCardCombine({ activityId:'1001', cardTemplateIds });cardTemplateIds 为当前奖池用户已拥有且数量大于 0 的不同卡片 templateId 字符串数组;R 需要 5 个不同 R 卡,SR 需要 3 个不同 SR 卡;成功后刷新当前等级卡包和目标等级卡包",
@@ -427,21 +427,21 @@
"layout": "flex-column rows + row flex center",
"gap": "R 使用 3vw;SR/SSR 使用 lottery-prize-list-large 将间距缩小为 1vw",
"itemWidth": "R/SR 使用 calc((100% - var(--lottery-prize-gap) * 2) / 3);SSR 使用 calc((100% - var(--lottery-prize-gap)) / 2.2)",
- "modeSwitch": "左右 leftBt 切换 lotteryR / lotterySR / lotterySSR,切换时重置 activeLotteryIndex;抽奖动画执行中暂不允许切换奖池",
+ "modeSwitch": "左右 leftBt 切换 lotteryR / lotterySR / lotterySSR,切换时重置 activeLotteryIndex;奖励池抽奖请求中暂不允许切换奖池",
"defaultImage": "lotteryPrizeBg",
"activeImage": "lotteryPrizeActiveBg",
"apiData": "有 /activity/card/reward/prize/list 接口数据时优先使用接口返回数组;字段为 id、poolType、prizeName、prizeType、prizeValue、prizeImage、sortOrder;按 sortOrder 升序排序,并补充当前渲染 index 用于 lotteryPrizeActiveBg 高亮动画",
- "itemContent": "每个奖品仍使用 itemCenter,背景根据 activeLotteryIndex 切换 lotteryPrizeBg / lotteryPrizeActiveBg;itemCenter 内部使用垂直 flex,第一行 prizeImage 宽度 60%,第二行展示奖励信息;奖励信息使用 formatRewardDisplay(prize.prizeType, { content: prize.prizeValue, quantity: prize.prizeValue }),无结果时回退 prizeName",
+ "itemContent": "每个奖品仍使用 itemCenter,背景根据 activeLotteryIndex 切换 lotteryPrizeBg / lotteryPrizeActiveBg;itemCenter 内部使用垂直 flex,第一行 prizeImage 宽度 60%,第二行展示奖励信息;奖励信息有 days 时展示 `${days} days`,没有 days 时展示 prizeValue,不展示 prizeName",
"remark": "奖品区域按行渲染,每一行使用 flex 居中,避免 grid 在最后一行数量不足时贴左;没有接口数据时保留本地测试奖品占位,方便预览。"
},
"animation": {
"reference": "/activities/lucky-dollars-season5",
- "mode": "切换奖品 itemCenter 的背景图来模拟转圈,而不是叠加 prizeActiveFrame 发光边框",
+ "mode": "当前奖励池抽奖接口成功后先播放 lotteryPrizeActiveBg 模拟转圈动画,动画结束后再展示 resultBg 结果弹窗",
"activeRule": "同一时间只有一个奖品 itemCenter 使用 lotteryPrizeActiveBg,其余使用 lotteryPrizeBg",
"order": "从左到右、从上到下依次变化背景图",
"resultFlow": "点击抽奖后先请求接口,接口返回中奖奖品,再根据奖品 id/name 计算最终位置,然后启动动画并停在中奖位置",
- "multiDraw": "5 连抽也只执行一次动画,最终位置取接口返回结果中的第一个中奖奖品",
- "currentPreview": "requestLotteryDraw 已接入 /activity/card/reward/draw;接口结果暂未映射具体中奖奖品位置时,动画仍优先停在当前奖池第 4 个奖品(数组下标 3),如果当前奖池不足 4 个,则停在最后一个奖品;后续根据接口返回 prizeId/name 再补最终位置映射",
+ "multiDraw": "5 连抽也直接展示结果弹窗,弹窗图片取接口返回结果中的第一个中奖奖品;没有中奖标记时取第一条结果",
+ "currentPreview": "requestLotteryDraw 已接入 /activity/card/reward/draw;接口成功后先计算中奖停点并播放模拟动画,动画结束后再展示 resultBg 结果弹窗,底部按钮文本为 Confirm,点击后关闭弹窗",
"performanceNote": "当前奖品数量少,直接切换两个 itemCenter 的 imgUrl 可以满足效果;后续如果奖品数量增加、动画频率提高或低端机出现闪烁/卡顿,优先考虑改成固定默认背景 + 单个 active 覆盖层,或用 class 切换预加载后的 CSS background,以减少图片 src 变更。",
"decisionRule": "只要满足效果和布局,优先选择更省性能、用户体验更稳定的方案;如果 Codex 发现更好的样式或动画实现,需要先说明方案、优缺点和影响范围,并询问是否采用,不能静默替换用户已调好的结构。"
},
@@ -857,7 +857,7 @@
"topLayer": "display:flex; flex:1; align-items:center; gap:4vw",
"sideColumns": "左右两列 width:10vw; flex:0 0 10vw; 内部居中放 leftBt,右侧 leftBt 水平翻转;点击左右按钮切换 lotteryR / lotterySR / lotterySSR",
"prizeGrid": "中间 flex:1;使用 flex-direction:column 分行渲染,行内 flex 居中;R 使用 --lottery-prize-gap:3vw,SR/SSR 使用 lottery-prize-list-large 缩小到 1vw;接口返回奖品按 sortOrder 排序后渲染,lotteryR 默认行数 2/3/2,lotterySR 3 个一行展示并放大,lotterySSR 2 个一行展示并更大;默认 lotteryPrizeBg,当前 activeLotteryIndex 使用 lotteryPrizeActiveBg;奖品 itemCenter 内部垂直展示 60% 宽 prizeImage 和 formatRewardDisplay 生成的奖励信息",
- "animation": "参考 LuckyDollars Season5 的接口返回后再开启动画逻辑;当前页面不叠加发光边框,而是通过 lotteryPrizeBg / lotteryPrizeActiveBg 背景图切换模拟转圈。点击 1 抽或 5 连抽都只执行一次动画,顺序按当前奖池渲染数组从左到右、从上到下,最终停在接口结果对应位置;当前接口结果未映射时优先停第 4 个,奖池不足 4 个时停最后一个。",
+ "animation": "当前奖励池抽奖播放 lotteryPrizeActiveBg 模拟转圈动画;点击 1 抽或 5 连抽接口成功后先计算中奖停点并播放动画,动画结束后再展示 resultBg 结果弹窗,底部按钮文本为 Confirm,点击后关闭弹窗。",
"performanceDecision": "当前先保留直接切换 imgUrl 的实现,因为测试奖品数量少且两张图已预加载,成本可控。若后续体验不稳,优先提议改为默认背景常驻 + 单个 active 覆盖层,或 class 切 CSS background;任何更优样式方案先询问用户是否采用。",
"bottomLayer": "display:flex; align-items:center; justify-content:space-around; gap:0; margin:0; padding:0;draw1Bt 和 draw5BtBg 宽度 38vw;两个按钮内部水平居中、垂直贴底展示 10vw 当前奖池票券图,切换 lotteryR / lotterySR / lotterySSR 时同步切换 ticketR / ticketSR / ticketSSR;draw1Bt 需要当前奖池奖励票数量 >= 1,draw5BtBg 需要 >= 5,不满足时置灰且不给点击;点击调用 /activity/card/reward/draw,ticketType 使用当前奖池 poolType,drawCount 为 1 或 5,成功后刷新第一层三种票券数量"
},
@@ -892,8 +892,8 @@
"rankingModule": {
"source": "布局参考 /activities/lucky-dollars-season5;数据接口改用 /activity/week-star/ranking 和 /activity/week-star/my-rank",
"top3": "前三名使用 GulbenFestival 页面私有 TopUser 组件 src/views/Activities/GulbenFestival/components/topUser.vue,不修改 LuckyDollars / GamesKing 等参考页面组件;边框图使用 Gulben 的 top1 / top2 / top3;布局为左中右一行,顺序第二名、第一名、第三名,第二/三名 flex:1; min-width:0,第一名 flex:1.25; min-width:0;贡献值样式在私有组件中模仿 src/views/Ranking/GamesKing/components/topUserDaily.vue,使用金币图标、圆角渐变底、#ffdf36 描边、内阴影和按名次区分的渐变色;排名文字和贡献值文字统一使用白色",
- "listAfterTop3": "从第四名开始渲染列表;当前使用 Gulben 的 RankingItem 图片承载列表项;排名文字和贡献值文字统一使用白色;贡献值参考 WeeklyStar 使用金币图标 + quantity/amount,不再显示 $;名称/id 的 flex-column 中新增一行等级图,依次展示 vip1、itemCenter(userLevel1Bg)、itemCenter(wealthLevel1Bg),后两者宽度暂用 10vw",
- "myRanking": "切换到 Personal Rank / Ranking 时固定贴底展示我的排名;当前同样使用 RankingItem 承载;排名文字和贡献值文字统一使用白色;贡献值使用金币图标,名称下方同步展示 vip1 / userLevel1Bg / wealthLevel1Bg 等级占位",
+ "listAfterTop3": "从第四名开始渲染列表;当前使用 Gulben 的 RankingItem 图片承载列表项;排名文字和贡献值文字统一使用白色;贡献值参考 WeeklyStar 使用金币图标 + quantity/amount,不再显示 $;名称/id 的 flex-column 中新增一行等级图,vip 图片根据 rankingList 列表项 vipLevelName 按 VISCOUNT->vip1、EARL->vip2、MARQUIS->vip3、DUKEI->vip4、KING->vip5、EMPEROR->vip6 映射展示,没有 vipLevelName 时不展示;userLevel 背景图根据 charmLevel 按 1-10/11-20/21-30/31-40/41-50 对应 userLevel1Bg~userLevel5Bg,wealthLevel 背景图根据 wealthLevel 按同样分段对应 wealthLevel1Bg~wealthLevel5Bg,内部居中展示对应等级文字,文字白色 font-size:1em",
+ "myRanking": "切换到 Personal Rank / Ranking 时固定贴底展示我的排名;当前同样使用 RankingItem 承载;排名文字和贡献值文字统一使用白色;贡献值使用金币图标,名称下方同步展示 vip / userLevelBg / wealthLevelBg;vip 图片根据 myRanking.vipLevelName 按 VISCOUNT->vip1、EARL->vip2、MARQUIS->vip3、DUKEI->vip4、KING->vip5、EMPEROR->vip6 映射展示,没有 vipLevelName 时不展示;userLevel 背景图根据 myRanking.charmLevel 按 1-10/11-20/21-30/31-40/41-50 对应 userLevel1Bg~userLevel5Bg,wealthLevel 背景图根据 myRanking.wealthLevel 按同样分段对应 wealthLevel1Bg~wealthLevel5Bg,内部居中展示对应等级文字,文字白色 font-size:1em",
"pagination": "pageSize=10;current 从 1 开始;接口返回 rankUsers.records 追加到 ranking;当 current * rankUsers.size >= rankUsers.total 时停止上拉;rankingLoading / rankingHasMore 防止重复请求"
},
"data": "pageConfig.activity.personalRankActivityType 默认 THIS_WEEK;weekly-star 字段映射为 userNickname -> userName、quantity -> amount;礼物临时使用 KingAndQueen templateId=1966020557831610370"
@@ -968,12 +968,13 @@
- 补充规则弹窗:点击首行 `ruleBtBg` 打开 `MaskLayer`,弹窗内容用 `itemCenter` 承载 `ruleInfoBg`,点击遮罩关闭、点击弹窗内容阻止冒泡;规则文字使用 `color:#FFF; font-size:1em; font-weight:600`;内容区 `max-height:62vh` 内可上下滑动,并通过 `rule-popup-scroll` 隐藏滚动条;规则文案按标题和 `(1)/(2)/(3)` 拆成独立行渲染
- 补充集卡活动基础接口:Gulben 页面调用卡片相关接口时固定传字符串 `activityId:'1001'`,`src/api/activity.js` 中的接口方法保持通用,不写死活动 ID;页面初始化时调用 `apiGetCardTicketList('1001')` 查询用户票券数量、按 `STANDARD/PREMIUM/LUXURY` 三个 `poolType` 调用 `apiGetCardRewardPrizeList('1001', poolType)` 查询奖励池奖品列表,分别对应页面展示 `R/SR/SSR`;调用 `apiGetCardRewardMarquee('1001')` 查询中奖飘窗列表
- 补充用户卡包查询:页面初始化时调用 `apiPostCardBagList({ activityId:'1001', cardGrade, current:1, size:20 })`,按 `R/SR/SSR` 三个 `cardGrade` 分别查询并存储;集卡玩法第四层固定渲染 R=5、SR=3、SSR=2 个卡位,接口 records 中返回对应 `cardTemplateId` 时,用 `redeemR2001~redeemR2005`、`redeemSR2011~redeemSR2013`、`redeemSSR2021~redeemSSR2022` 替换默认 `cardBackBg`,没有返回的卡位继续展示 `cardBackBg`;正面卡片使用 `itemCenter` 承载,并在右上角展示 `quantity` 数量角标,`cardBackBg` 不展示角标
-- 对接抽奖区奖品池展示:`/activity/card/reward/prize/list` 返回的奖品按当前奖池 `poolType` 存储,渲染时按 `sortOrder` 升序排序并补充 `index`;奖品 itemCenter 背景继续用 `lotteryPrizeBg / lotteryPrizeActiveBg` 参与动画,内部垂直展示 `prizeImage`(宽度 60%)和奖励信息;奖励信息用 `formatRewardDisplay(prize.prizeType, { content: prize.prizeValue, quantity: prize.prizeValue })` 生成,失败时回退 `prizeName`
-- 补充抽奖动画占位逻辑:参考 `/activities/lucky-dollars-season5` 的“接口返回结果后计算最终位置再开启动画”,当前通过 `lotteryPrizeBg` / `lotteryPrizeActiveBg` 切换背景图模拟转圈;5 连抽只执行一次动画
+- 对接抽奖区奖品池展示:`/activity/card/reward/prize/list` 返回的奖品按当前奖池 `poolType` 存储,渲染时按 `sortOrder` 升序排序并补充 `index`;奖品 itemCenter 背景继续用 `lotteryPrizeBg / lotteryPrizeActiveBg` 参与动画,内部垂直展示 `prizeImage`(宽度 60%)和奖励信息;奖励信息有 `days` 时展示 `${days} days`,没有 `days` 时展示 `prizeValue`,不展示 `prizeName`
+- 补充奖励池抽奖结果逻辑:`/activity/card/reward/draw` 接口成功后先计算中奖停点并播放 `lotteryPrizeActiveBg` 模拟转圈动画,动画结束后再展示 `resultBg` 结果弹窗;底部按钮文本为 `Confirm`,点击后关闭弹窗
- 补充 `collectGameBg` 五层预览结构:票券数量、提示文案、当前奖池标题、当前奖池卡牌、操作按钮;支持 `gameR` / `gameSR` / `gameSSR` 本地切换
- 补充 CollectCards 第三个任务模块 `taskModule`:任务接口和字段先参考 LuckyDollars Season5,列表项使用 `itemCenter` + `taskItemBg`,contentStyle 固定为 `display:flex; padding:8px; justify-content:space-between; align-items:center`,不引用 Season5 的任务类型图标和票券图标;右侧任务按钮也改为 `itemCenter` 背景图 + 翻译文字;Gulben 页面中任务列表和领取任务等涉及 `activityId` 的接口统一固定传 `gulbenActivityId` 的值,也就是字符串 `'1001'`
- 补充 Personal Rank / Reward 预览结构:只展示 `RewardTop1`、`RewardTop2`、`RewardTop3` 三个 100vw 的 `itemCenter` 模块,从上往下排序;奖励数据调用 `apiGetActivityResource(rewardActivityType)`,当前 `rewardActivityType` 临时使用 `DAILY_RECHARGE_TASK2` 便于预览;接口返回 10 套奖励时只取前 3 套,每套最多 6 个奖励;如果某套只有 5 个奖励,则复制第 5 个奖励补到第 6 个位置用于预览;布局参考 DailyRecharge,左侧主奖励大图,右侧小奖励固定按 2/2/1 三行展示,第三行即使没有奖励也保留单展示位不可见占位并居中;奖励文案统一使用 `formatRewardDisplay(reward.type, reward)`
-- 补充 Personal Rank / Ranking 预览结构:活动礼物模块使用 `giftBg` 的 3 个预设空位直接展示 giftPhoto / giftCandy,不额外套参考页的 `gift` 单项背景;排行榜布局参考 LuckyDollars Season5,数据改用 `/activity/week-star/ranking` 和 `/activity/week-star/my-rank`;前三名使用 GulbenFestival 私有 `components/topUser.vue` + `top1/top2/top3`,布局为第二名/第一名/第三名左中右排列且第一名稍宽;贡献值参考 WeeklyStar 使用金币图标,前三名贡献值外观在私有组件内参考 GamesKing 的 `topUserDaily.vue` 圆角渐变底;排行榜排名和贡献值文字统一使用白色;第四名以后和我的排名使用 `RankingItem` 承载,名称下方新增 `vip1`、`userLevel1Bg`、`wealthLevel1Bg` 等级行,切换到该标签时贴底展示我的排名,并通过底部 `rankingLoadmore` 上拉加载更多
+- 补充 Personal Rank / Ranking 预览结构:活动礼物模块使用 `giftBg` 的 3 个预设空位直接展示 giftPhoto / giftCandy,不额外套参考页的 `gift` 单项背景;排行榜布局参考 LuckyDollars Season5,数据改用 `/activity/week-star/ranking` 和 `/activity/week-star/my-rank`;前三名使用 GulbenFestival 私有 `components/topUser.vue` + `top1/top2/top3`,布局为第二名/第一名/第三名左中右排列且第一名稍宽;贡献值参考 WeeklyStar 使用金币图标,前三名贡献值外观在私有组件内参考 GamesKing 的 `topUserDaily.vue` 圆角渐变底;排行榜排名和贡献值文字统一使用白色;第四名以后和我的排名使用 `RankingItem` 承载,名称下方新增 vip 等级图、userLevelBg、wealthLevelBg 等级行,vip 图片根据 `vipLevelName` 按 `VISCOUNT->vip1`、`EARL->vip2`、`MARQUIS->vip3`、`DUKEI->vip4`、`KING->vip5`、`EMPEROR->vip6` 映射展示,没有 `vipLevelName` 时不展示;userLevel 背景图根据 `charmLevel` 按 1-10/11-20/21-30/31-40/41-50 对应 `userLevel1Bg~userLevel5Bg`,wealthLevel 背景图根据 `wealthLevel` 按同样分段对应 `wealthLevel1Bg~wealthLevel5Bg`,内部居中展示对应等级文字,文字白色且 `font-size:1em`;切换到该标签时贴底展示我的排名,并通过底部 `rankingLoadmore` 上拉加载更多
+- `Personal Rank / Reward` 和 `Personal Rank / Ranking` 的活动奖励、活动礼物都来自 `/activity/king-queen/effective?templateId=2057676469390872578`;页面内必须通过共享缓存方法读取,避免进入 Reward 和 Ranking 时重复请求同一个 effective 接口。
- 补充 Room Rank / Reward 和 Ranking:Reward 直接 100vw 展示 `ruleActivity`;Ranking 复用 Personal Rank 的排行榜展示和 weekly-star 上拉分页,但不展示活动礼物模块,头像统一 `border-radius:4px`,列表和我的排名只展示名称、ID、金币数量,不展示 `vip1`、`userLevel1Bg`、`wealthLevel1Bg` 三个等级图片
- 创建本地 OSS 目录 `public/oss/h5/Azizi/Activities/GulbenFestival/`
- 添加路由 `/activities/gulben-festival`
@@ -1024,25 +1025,26 @@
- `connectToAppHandler()` 内通过 `connectToApp(() => { completePreloading() })` 在 APP 连接成功或浏览器环境初始化完成后再执行 `completePreloading()`;因此 `initData()`、首屏 blocking 图片预加载、后台图片预加载都在 APP header / 用户信息处理后触发。
- Room Rank 的 UI 仍复用当前排行榜结构,不展示活动礼物模块;头像保持 `border-radius:4px`,列表和我的排名只展示名称、ID、金币数量,不展示 `vip1`、`userLevel1Bg`、`wealthLevel1Bg`。
-## 本次同步:奖励池抽奖结果停点
+## 本次同步:奖励池抽奖结果弹窗
- `/activity/card/reward/draw` 返回数据按 `body.rewards` 解析;兼容旧的 `body.results` 和直接数组返回,最终统一存入 `drawResult.results`。
-- 单抽时使用返回的第一条抽奖结果定位奖品位置。
-- 5 连抽只执行一次转圈动画,优先取 `rewards` 中第一个 `isWin === true` 的奖品作为停点;如果没有中奖标记,则退回使用第一条结果。
-- 停点匹配优先级为 `prizeId -> prizeName -> prizeImage -> prizeType + prizeValue`,匹配当前奖池 `activeLotteryPrizes` 中对应奖品后,将 `lotteryPrizeActiveBg` 停在该奖品位置。
+- 单抽和 5 连抽接口成功后先播放奖励池模拟转圈动画,动画结束后再展示 `resultBg` 结果弹窗。
+- 结果弹窗图片优先取 `rewards` 中第一个 `isWin === true` 的奖品;如果没有中奖标记,则退回使用第一条结果;奖励池抽奖结果图宽度为 `45vw`,抽卡/合成结果图继续使用 `28vw`。
+- 结果弹窗底部按钮文本为 `Confirm`,点击后关闭弹窗。
+- 停点匹配优先级为 `prizeId -> prizeImage -> prizeType + prizeValue`,匹配当前奖池 `activeLotteryPrizes` 中对应奖品后,将 `lotteryPrizeActiveBg` 停在该奖品位置。
- 匹配不到接口奖品时才使用本地 `prizeIndex` 兜底;没有任何可用停点时停在当前奖池第一个奖品,避免继续使用旧测试逻辑固定停第 4 个。
## 本次同步:抽奖区奖励文案
-- `lotteryBg` 奖品 itemCenter 内第二行奖励信息直接展示接口返回的 `prizeName`。
-- 该区域不再使用 `formatRewardDisplay(prize.prizeType, { content: prize.prizeValue, quantity: prize.prizeValue })` 拼接奖励文案,避免和接口配置的奖品名称不一致。
+- `lotteryBg` 奖品 itemCenter 内第二行奖励信息不展示接口返回的 `prizeName`。
+- 该区域直接按接口字段展示:有 `days` 时展示 `${days} days`,没有 `days` 时展示 `prizeValue`。
## 本次同步:抽奖动画防抖动
- `lotteryBg` 奖品格子的 `itemCenter` 背景固定使用 `lotteryPrizeBg`,抽奖动画不再通过切换 `:imgUrl` 改变背景图。
- `lotteryPrizeActiveBg` 改为放在奖品格子内部的绝对定位覆盖图,通过 `lottery-prize-active-bg-show` 切换 `opacity` 展示选中态。
- 由于 `lotteryPrizeActiveBg` 和 `lotteryPrizeBg` 只有颜色差异,使用覆盖图透明度切换可以避免动画过程中反复改图片 `src`,减少移动端图片重新解码、布局重算导致的页面抖动。
-- 奖品图片和 `prizeName` 文案统一放到覆盖图上层,使用 `position:relative; z-index:1` 保持显示层级。
+- 奖品图片和奖励信息统一放到覆盖图上层,使用 `position:relative; z-index:1` 保持显示层级。
## 本次同步:抽奖高亮防抖动方案
@@ -1063,7 +1065,7 @@
- `lotteryPrizeActiveBg` 仍然用于抽奖选中态视觉,但不再作为 `itemCenter` 的 `imgUrl` 频繁切换。
- 奖品格 `itemCenter` 底图固定为 `imageUrl('lotteryPrizeBg')`。
- 每个奖品格内部常驻一张绝对定位的 `lotteryPrizeActiveBg` 覆盖图,class 为 `lottery-prize-active-bg`。
-- 当前选中奖品只切换 `lottery-prize-active-bg-show`,通过 `opacity` 展示 active 图;奖品图片和 `prizeName` 使用 `z-index:1` 保持在 active 图上层。
+- 当前选中奖品只切换 `lottery-prize-active-bg-show`,通过 `opacity` 展示 active 图;奖品图片和奖励信息使用 `z-index:1` 保持在 active 图上层。
- 这样既保留 `lotteryPrizeActiveBg` 的设计效果,也避免抽奖动画中反复修改 `itemCenter` 背景图 `src` 导致页面抖动。
## 本次同步:抽奖默认选中态