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 @@
- +
+
+ + +
+ +
+
+
+ +
+
+ {{ getHistoryRecordPrizeText(record) }} +
+
+
+ {{ formatHistoryRecordTime(record.drawTime) }} +
+
+ +
+
+
+
+ + +
+ + +
+ +
+ 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` 导致页面抖动。 ## 本次同步:抽奖默认选中态