From ece1142624f32f7bf9002ccc051631a8b0977c4d Mon Sep 17 00:00:00 2001
From: hzj <1304805162@qq.com>
Date: Sat, 23 May 2026 14:36:29 +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/api/activity.js | 16 +
src/components/itemCenter.vue | 2 +-
src/directives/smartImage.js | 13 +-
src/router/index.js | 12 +-
.../GulbenFestival/components/topUser.vue | 4 +-
src/views/Activities/GulbenFestival/index.vue | 547 +++++---
.../Activities/GulbenFestival/page.config.js | 25 +-
.../Activities/GulbenFestival/页面创建信息.md | 1102 +++++++++++++++++
8 files changed, 1506 insertions(+), 215 deletions(-)
create mode 100644 src/views/Activities/GulbenFestival/页面创建信息.md
diff --git a/src/api/activity.js b/src/api/activity.js
index eb3a12f..a0348b4 100644
--- a/src/api/activity.js
+++ b/src/api/activity.js
@@ -212,3 +212,19 @@ export const apiPostCardBagList = async (data) => {
throw error
}
}
+
+// 查询中奖记录
+export const apiPostCardRewardRecordList = async (data = {}) => {
+ try {
+ const response = await post('/activity/card/reward/record/list', {
+ size: 10,
+ current: 1,
+ ...data,
+ })
+ return response
+ } catch (error) {
+ console.error('Failed to get card reward record list:', error)
+ console.error('error:' + (error.response?.errorMsg || error.errorMsg || error.message || ''))
+ throw error
+ }
+}
diff --git a/src/components/itemCenter.vue b/src/components/itemCenter.vue
index 39a8539..4293b39 100644
--- a/src/components/itemCenter.vue
+++ b/src/components/itemCenter.vue
@@ -4,7 +4,7 @@
import('../views/Activities/LuckyDollars/Season5/index.vue'),
- meta: { requiresAuth: true },
- }, //2026年x月x日上午5点结束
{
path: '/activities/gulben-festival',
name: 'gulben-festival',
component: () => import('../views/Activities/GulbenFestival/index.vue'),
meta: { requiresAuth: true },
}, // 古尔邦节活动
+ {
+ path: '/activities/lucky-dollars-season5',
+ name: 'lucky-dollars-season5',
+ component: () => import('../views/Activities/LuckyDollars/Season5/index.vue'),
+ meta: { requiresAuth: true },
+ }, //2026年x月x日上午5点结束
{
path: '/activities/poker-ace',
name: 'pokerAce',
diff --git a/src/views/Activities/GulbenFestival/components/topUser.vue b/src/views/Activities/GulbenFestival/components/topUser.vue
index b92aa8d..f2486c4 100644
--- a/src/views/Activities/GulbenFestival/components/topUser.vue
+++ b/src/views/Activities/GulbenFestival/components/topUser.vue
@@ -2,7 +2,7 @@
![]()
+
- {{ tab.label }}
+
+ {{ tab.label }}
-
+
@@ -150,10 +157,19 @@
>
- {{ tab.label }}
+
+ {{
+ tab.label
+ }}
@@ -177,7 +193,36 @@
style="width: 100vw; margin: 0; padding: 0"
contentStyle="padding: 8vw 5vw 0; align-items: stretch; justify-content: flex-start; box-sizing: border-box;"
>
-
+
+
+ history
+
+
+
+
@@ -194,7 +239,7 @@
@click="changeLotteryPoolMode(-1)"
>
![]()
+
-
+
Rewards:
![]()
-
+
{{ t('task_received') }}
@@ -732,10 +796,18 @@
style="width: 100vw; margin: 0; padding: 0"
contentStyle="padding: 25.5vw 9vw 5vw; align-items: flex-start; justify-content: center; box-sizing: border-box;"
>
-
+
![]()
- handleRewardImageError(event, getRewardTopMainReward(rewardTop).type)
+ handleRewardImageError(
+ event,
+ getRewardTopMainReward(rewardTop, topIndex).type,
+ )
"
/>
+
- {{ getRewardDisplayText(getRewardTopMainReward(rewardTop)) }}
+ {{ getRewardDisplayText(getRewardTopMainReward(rewardTop, topIndex)) }}
@@ -784,7 +861,7 @@
style="flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1.7vw"
>
@@ -818,11 +895,17 @@
"
>
![]()
handleRewardImageError(event, reward.type)"
/>
+
-
![]()
+
getTicketRemainingCount('CARD'))
const maskLayerShow = computed(() => ruleShow.value)
-const weeklyStarRankingType = computed(() => {
- return pageConfig.activity.personalRankActivityType || 'THIS_WEEK'
-})
-
const activeRankingLoadKey = computed(() => {
if (isRoomRankingSection.value) {
return `room-${roomRankingParams.startDate}-${roomRankingParams.endDate}-${roomRankingParams.size}`
}
- return `personal-${weeklyStarRankingType.value}`
+ return `personal-${activityType.value}`
})
-const personalRewardActivityType = computed(() => {
- return pageConfig.activity.rewardActivityType || 'DAILY_RECHARGE_TASK2'
-})
-
-const rankingTotal = computed(() => [...ranking.value, ...rankingAdd.value])
+const rankingTotal = computed(() => ranking.value)
const rankingTop3 = computed(() => {
const top3 = [...rankingTotal.value]
@@ -1758,35 +1833,8 @@ const isPersonalRewardSection = computed(() => {
)
})
-const fallbackPersonalRewardTops = computed(() => {
- const rewardImages = [
- 'redeemR2001',
- 'redeemR2002',
- 'redeemR2003',
- 'redeemR2004',
- 'redeemR2005',
- 'redeemSR2011',
- ]
-
- return Array.from({ length: 3 }, (_, topIndex) => ({
- rule: {
- id: `fallback-${topIndex}`,
- },
- propsGroup: {
- activityRewardProps: rewardImages.map((imageName, rewardIndex) => ({
- type: 'GIFT',
- cover: imageUrl(imageName),
- content: `${rewardIndex + 1}`,
- })),
- },
- }))
-})
-
const personalRewardTops = computed(() => {
- const rewardTops = activityRewards.value.length
- ? activityRewards.value
- : fallbackPersonalRewardTops.value
- return rewardTops.slice(0, 3)
+ return activityRewards.value.slice(0, 3)
})
const changeCollectGameMode = (step) => {
@@ -1821,6 +1869,68 @@ const showTarget = (task) => {
})
}
+ if (task.targetType === 'SEND_GIFT') {
+ if (task.taskCode === 'SP_SEND_GIFT_5') {
+ return t('Send 5 Ramadan gifts ({currentValue}/{targetValue})', {
+ currentValue: task.currentValue,
+ targetValue: task.targetValue,
+ })
+ }
+ }
+
+ if (task.targetType === 'SAVE_COINS') {
+ if (task.taskCode === 'SP_SAVE_50000') {
+ return t('Win 50,000 coins in total across any games ({currentValue}/{targetValue})', {
+ currentValue: task.currentValue,
+ targetValue: task.targetValue,
+ })
+ }
+
+ if (task.taskCode === 'SP_SAVE_100000') {
+ return t('Win 100,000 coins in total across any games ({currentValue}/{targetValue})', {
+ currentValue: task.currentValue,
+ targetValue: task.targetValue,
+ })
+ }
+
+ if (task.taskCode === 'SP_SAVE_500000') {
+ return t('Win 500,000 coins in total across any games ({currentValue}/{targetValue})', {
+ currentValue: task.currentValue,
+ targetValue: task.targetValue,
+ })
+ }
+ }
+
+ if (task.targetType === 'RECHARGE_AMOUNT') {
+ if (task.taskCode === 'SP_RECHARGE_1') {
+ return t('Daily total recharge amount: $1 ({currentValue}/{targetValue})', {
+ currentValue: task.currentValue,
+ targetValue: task.targetValue,
+ })
+ }
+
+ if (task.taskCode === 'SP_RECHARGE_10') {
+ return t('Daily total recharge amount: $10 ({currentValue}/{targetValue})', {
+ currentValue: task.currentValue,
+ targetValue: task.targetValue,
+ })
+ }
+
+ if (task.taskCode === 'SP_RECHARGE_50') {
+ return t('Daily total recharge amount: $50 ({currentValue}/{targetValue})', {
+ currentValue: task.currentValue,
+ targetValue: task.targetValue,
+ })
+ }
+
+ if (task.taskCode === 'SP_RECHARGE_100') {
+ return t('Daily total recharge amount: $100 ({currentValue}/{targetValue})', {
+ currentValue: task.currentValue,
+ targetValue: task.targetValue,
+ })
+ }
+ }
+
return `${task.taskName}(${task.currentValue}/${task.targetValue})`
}
@@ -1931,10 +2041,7 @@ const handleCardDraw = async (drawCount = 1) => {
})
if (resDraw.status) {
- await Promise.all([
- getCardTicketList(), // 刷新 CARD 抽卡票数量
- getCardBagListByGrade('R'), // 抽卡只产出 R 级卡片,刷新当前 R 卡数量
- ])
+ await Promise.all([getCardTicketList(), getCardBagListByGrade('R')])
}
} catch (error) {
showError(error.errorMsg)
@@ -1993,8 +2100,8 @@ const handleCardRedeem = async () => {
if (resRedeem.status) {
await Promise.all([
- getCardTicketList(), // 刷新 STANDARD / PREMIUM / LUXURY 奖励票数量
- getCardBagListByGrade(activeCollectGameMode.value.cardGrade), // 刷新当前奖池卡片数量
+ getCardTicketList(),
+ getCardBagListByGrade(activeCollectGameMode.value.cardGrade),
])
}
} catch (error) {
@@ -2026,7 +2133,8 @@ const receiveTaskReward = async (code) => {
taskCode: code,
})
if (resReceive.status && resReceive.body) {
- getTaskList()
+ getTaskList() // 获取任务列表
+ getCardTicketList() // 查询用户票券数量
}
} catch (error) {
showError(error.errorMsg)
@@ -2034,63 +2142,88 @@ const receiveTaskReward = async (code) => {
}
const getWeeklyGifts = async () => {
- const resRewardsAndGifts = await getThisWeekRewardsAndGifts(kingQueenGiftTemplateId)
+ const resRewardsAndGifts = await getThisWeekRewardsAndGifts(templateId.value)
if (resRewardsAndGifts.status && resRewardsAndGifts.body?.gifts?.length) {
weeklyGifts.value = resRewardsAndGifts.body.gifts.slice(0, 3)
}
}
-const getPersonalRewardList = (rewardTop) => {
- const rewards = rewardTop?.propsGroup?.activityRewardProps
+const getPersonalRewardExtraItem = (topIndex) => {
+ if (topIndex === 0) {
+ return {
+ type: 'EXTRA_REWARD_ITEM',
+ quantity: 30,
+ cover: imageUrl('top1ExtraRewardItem'),
+ isExtraPreviewReward: true,
+ }
+ }
+
+ if (topIndex === 1) {
+ return {
+ type: 'EXTRA_REWARD_ITEM',
+ quantity: 30,
+ cover: imageUrl('top2ExtraRewardItem'),
+ isExtraPreviewReward: true,
+ }
+ }
+
+ return null
+}
+
+const getPersonalRewardList = (rewardTop, topIndex) => {
+ const rewards = rewardTop?.rewards || rewardTop?.propsGroup?.activityRewardProps
if (!Array.isArray(rewards)) {
return []
}
const rewardList = rewards.slice(0, 6)
+ const extraReward = getPersonalRewardExtraItem(topIndex)
+ if (extraReward && rewardList.length < 6) {
+ rewardList.push(extraReward)
+ }
+
if (rewardList.length === 5) {
rewardList.push({
- ...rewardList[4],
- isCopiedPreviewReward: true,
+ isEmptyPreviewReward: true,
})
}
return rewardList
}
-const getRewardTopMainReward = (rewardTop) => {
- return getPersonalRewardList(rewardTop)[0] || null
+const getRewardTopMainReward = (rewardTop, topIndex) => {
+ return getPersonalRewardList(rewardTop, topIndex)[0] || null
}
-const getRewardTopGridRows = (rewardTop) => {
- const rewards = getPersonalRewardList(rewardTop).slice(1, 6)
+const getRewardTopGridRows = (rewardTop, topIndex) => {
+ const rewards = getPersonalRewardList(rewardTop, topIndex).slice(1, 6)
return [rewards.slice(0, 2), rewards.slice(2, 4), rewards.slice(4, 5)]
}
const getRewardDisplayText = (reward) => {
- if (!reward) {
+ if (!reward || reward.isEmptyPreviewReward) {
return ''
}
return formatRewardDisplay(reward.type, reward)
}
-const getLotteryPrizeDisplayText = (prize) => {
- if (!prize?.prizeType) {
- return ''
- }
+const hasRewardCover = (reward) => {
+ return Boolean(reward?.cover)
+}
- return (
- formatRewardDisplay(prize.prizeType, {
- ...prize,
- type: prize.prizeType,
- content: prize.prizeValue,
- quantity: prize.prizeValue,
- }) || prize.prizeName
- )
+const getLotteryPrizeDisplayText = (prize) => {
+ return prize?.prizeName || ''
}
const getRewardRenderKey = (reward, index) => {
- return [index, reward?.type || '', reward?.detailType || '', reward?.cover || ''].join('-')
+ return [
+ index,
+ reward?.isEmptyPreviewReward ? 'empty' : '',
+ reward?.type || '',
+ reward?.detailType || '',
+ reward?.cover || '',
+ ].join('-')
}
const getPersonalRewardConfigs = async () => {
@@ -2101,16 +2234,16 @@ const getPersonalRewardConfigs = async () => {
personalRewardLoaded.value = true
try {
- const res = await apiGetActivityResource(personalRewardActivityType.value)
- if (res.status && res.body) {
- activityRewards.value = Array.isArray(res.body) ? res.body.slice(0, 3) : []
+ const resRewardsAndGifts = await getThisWeekRewardsAndGifts(templateId.value)
+ if (resRewardsAndGifts.status && resRewardsAndGifts.body) {
+ activityRewards.value = (resRewardsAndGifts.body.butOneRewards || []).slice(0, 3)
}
} catch (error) {
showError(error.errorMsg)
}
}
-const normalizeWeeklyStarRankingItem = (item = {}, index = 0) => {
+const normalizePersonalRankingItem = (item = {}, index = 0) => {
return {
...item,
rank: item.rank || item.ranking || (index === null ? '' : index + 1),
@@ -2141,35 +2274,6 @@ const normalizeRoomContributionRankingItem = (item = {}, index = 0) => {
}
}
-const getUserInfo = async () => {
- if (userInfo.value?.id) {
- return userInfo.value
- }
-
- const resUserInfo = await getMemberProfile()
- if (resUserInfo.status && resUserInfo.body) {
- userInfo.value = resUserInfo.body
- }
-
- return userInfo.value
-}
-
-const getMyPersonalRanking = async () => {
- const profile = await getUserInfo()
- if (!profile?.id) {
- return
- }
-
- const resMyRanking = await getMyWeekStarRanking({
- type: weeklyStarRankingType.value,
- userId: profile.id,
- })
-
- if (resMyRanking.status && resMyRanking.body) {
- myRanking.value = normalizeWeeklyStarRankingItem(resMyRanking.body, null)
- }
-}
-
const getPersonalRanking = async () => {
if (rankingLoading.value || !rankingHasMore.value) {
return
@@ -2178,40 +2282,21 @@ const getPersonalRanking = async () => {
rankingLoading.value = true
try {
- const resRanking = await getWeekStarRanking({
- type: weeklyStarRankingType.value,
- current: rankingPageNo.value,
- size: weeklyStarPageSize,
+ const resRanking = await getRankingListAndMyRanking({
+ activityType: activityType.value,
})
- if (resRanking.status && resRanking.body?.rankUsers) {
- const rankUsers = resRanking.body.rankUsers
- const records = rankUsers.records || []
- const pageSize = Number(rankUsers.size || weeklyStarPageSize)
- const total = Number(rankUsers.total || 0)
- const startIndex = (rankingPageNo.value - 1) * pageSize
- const list = records.map((item, index) =>
- normalizeWeeklyStarRankingItem(item, startIndex + index),
+ if (resRanking.status && resRanking.body) {
+ myRanking.value = normalizePersonalRankingItem(resRanking.body.currentUserRank || {}, null)
+ ranking.value = (resRanking.body.rankingList || []).map((item, index) =>
+ normalizePersonalRankingItem(item, index),
)
-
- if (rankingPageNo.value === 1) {
- ranking.value = list
- } else {
- ranking.value.push(...list)
- }
-
- rankingAdd.value = []
- rankingHasMore.value = records.length > 0 && rankingPageNo.value * pageSize < total
-
- if (rankingHasMore.value) {
- rankingPageNo.value += 1
- }
-
+ rankingHasMore.value = false
return
}
ranking.value = []
- rankingAdd.value = []
+ myRanking.value = {}
rankingHasMore.value = false
} finally {
rankingLoading.value = false
@@ -2233,7 +2318,6 @@ const getRoomContributionRanking = async () => {
ranking.value = records.map((item, index) =>
normalizeRoomContributionRankingItem(item, index),
)
- rankingAdd.value = []
myRanking.value = resRanking.body.current
? normalizeRoomContributionRankingItem(resRanking.body.current, null)
: {}
@@ -2242,7 +2326,6 @@ const getRoomContributionRanking = async () => {
}
ranking.value = []
- rankingAdd.value = []
myRanking.value = {}
rankingHasMore.value = false
} finally {
@@ -2282,16 +2365,14 @@ const loadRankingSection = async () => {
rankingLoaded.value = true
loadedRankingType.value = rankingType
- rankingPageNo.value = 1
ranking.value = []
- rankingAdd.value = []
myRanking.value = {}
rankingHasMore.value = true
try {
const loaders = isRoomRankingSection.value
? [getRoomContributionRanking()]
- : [getMyPersonalRanking(), getPersonalRanking()]
+ : [getPersonalRanking()]
if (isPersonalRankingSection.value) {
loaders.unshift(getWeeklyGifts())
@@ -2314,11 +2395,6 @@ const observeRankingLoadmore = async () => {
rankingObserver.observe(rankingLoadmore.value)
}
-// 抽奖奖品占位:同一时间只允许一个奖品使用选中背景。
-const lotteryPrizeImage = (index) => {
- return imageUrl(index === activeLotteryIndex.value ? 'lotteryPrizeActiveBg' : 'lotteryPrizeBg')
-}
-
const normalizeLotteryIndex = (index) => {
const prizeCount = activeLotteryPrizes.value.length
if (!prizeCount) {
@@ -2328,8 +2404,32 @@ const normalizeLotteryIndex = (index) => {
return (((Number(index) || 0) % prizeCount) + prizeCount) % prizeCount
}
-// 抽奖结果位置计算:正式接口接入后,优先用接口返回的奖品 id/name 在奖池中查找位置。
+// 抽奖结果位置计算:优先用接口返回的奖品 id/name 在奖池中查找位置。
const getLotteryResultIndex = (drawResult) => {
+ const rewards = Array.isArray(drawResult?.results) ? drawResult.results : []
+ const resultReward = rewards.find((reward) => reward?.isWin) || rewards[0] || drawResult
+ const prizeList = activeLotteryPrizes.value
+
+ 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 =
+ resultReward?.prizeType &&
+ prize.prizeType &&
+ resultReward.prizeType === prize.prizeType &&
+ Number(resultReward?.prizeValue || 0) === Number(prize?.prizeValue || 0)
+
+ return samePrizeId || samePrizeName || samePrizeImage || samePrizeTypeAndValue
+ })
+
+ if (matchedPrizeIndex >= 0) {
+ return normalizeLotteryIndex(matchedPrizeIndex)
+ }
+
if (typeof drawResult?.prizeIndex === 'number') {
return normalizeLotteryIndex(drawResult.prizeIndex)
}
@@ -2351,8 +2451,11 @@ const startLotteryAnimation = (resultIndex) => {
}
const targetIndex = normalizeLotteryIndex(resultIndex)
+ const startIndex =
+ activeLotteryIndex.value >= 0 ? normalizeLotteryIndex(activeLotteryIndex.value) : 0
+ const distanceToTarget = (targetIndex - startIndex + prizeCount) % prizeCount
const minRounds = 3
- const totalSteps = prizeCount * minRounds + targetIndex + 1
+ const totalSteps = prizeCount * minRounds + distanceToTarget + 1
let step = 0
const roll = () => {
@@ -2363,11 +2466,11 @@ const startLotteryAnimation = (resultIndex) => {
return
}
- activeLotteryIndex.value = step % prizeCount
+ activeLotteryIndex.value = (startIndex + step) % prizeCount
step += 1
const progress = step / totalSteps
- const delay = 70 + Math.round(progress * progress * 190)
+ const delay = 130 + Math.round(progress * progress * 220)
lotteryAnimationTimer = window.setTimeout(roll, delay)
}
@@ -2389,10 +2492,22 @@ const requestLotteryDraw = async (drawCount) => {
return null
}
+ const responseBody = resRewardDraw.body || {}
+ const rewards = Array.isArray(responseBody?.rewards)
+ ? responseBody.rewards
+ : Array.isArray(responseBody?.results)
+ ? responseBody.results
+ : Array.isArray(responseBody)
+ ? responseBody
+ : responseBody
+ ? [responseBody]
+ : []
+
return {
drawCount,
- prizeIndex: Math.min(3, activeLotteryPrizes.value.length - 1),
- results: resRewardDraw.body || [],
+ batchNo: responseBody?.batchNo || '',
+ results: rewards,
+ raw: responseBody,
}
}
@@ -2468,15 +2583,6 @@ const startCountdown = () => {
countdownTimer = window.setInterval(calculateCountdown, 1000)
}
-// 标签按钮背景:根据当前激活状态切换默认图和选中图。
-const tabButtonImage = (value) => {
- return imageUrl(value === activeTopTab.value ? 'tagActiveBg' : 'tagBg')
-}
-
-const childTabButtonImage = (value) => {
- return imageUrl(value === activeChildTab.value ? 'moduleActiveBg' : 'moduleBg')
-}
-
const changeTopTab = (value) => {
activeTopTab.value = value
}
@@ -2688,6 +2794,26 @@ onUnmounted(() => {
.tab-background {
height: 100%;
+ overflow: hidden;
+ contain: layout paint;
+ isolation: isolate;
+ transform: translateZ(0);
+}
+
+.tab-background :deep(.scrollY) {
+ z-index: 1;
+ overflow: hidden;
+}
+
+.tab-active-bg {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: fill;
+ pointer-events: none;
}
.image-tab span {
@@ -2717,7 +2843,7 @@ onUnmounted(() => {
color: #5b0000;
}
-/* 第四行:当前标签内容占位区 */
+/* 第四行:当前标签内容区 */
.lottery-prize-list {
--lottery-prize-gap: 3vw;
}
@@ -2730,6 +2856,25 @@ onUnmounted(() => {
width: calc((100% - var(--lottery-prize-gap) * 2) / 3);
min-width: 0;
flex: 0 0 calc((100% - var(--lottery-prize-gap) * 2) / 3);
+ overflow: hidden;
+ contain: layout paint;
+ isolation: isolate;
+ transform: translateZ(0);
+}
+
+.lottery-prize-item :deep(.scrollY) {
+ z-index: 1;
+}
+
+.lottery-prize-active-bg {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: fill;
+ pointer-events: none;
}
.lottery-prize-item-sr {
diff --git a/src/views/Activities/GulbenFestival/page.config.js b/src/views/Activities/GulbenFestival/page.config.js
index adedc01..95e157f 100644
--- a/src/views/Activities/GulbenFestival/page.config.js
+++ b/src/views/Activities/GulbenFestival/page.config.js
@@ -6,8 +6,9 @@ export const pageConfig = {
activity: {
activityId: '1001',
+ templateId: '2057676469390872578',
activityCode: '',
- activityType: '',
+ activityType: 19,
personalRankActivityType: 'THIS_WEEK',
roomRankActivityType: '',
rewardActivityType: 'DAILY_RECHARGE_TASK2',
@@ -15,7 +16,27 @@ export const pageConfig = {
},
preload: {
- blocking: ['bg', 'ruleBtBg', 'timeBg', 'tagActiveBg', 'tagBg', 'lotteryBg', 'collectGameBg'],
+ blocking: [
+ 'bg',
+ 'ruleBtBg',
+ 'timeBg',
+ 'tagActiveBg',
+ 'tagBg',
+ 'moduleActiveBg',
+ 'moduleBg',
+ 'lotteryBg',
+ 'collectGameBg',
+ 'taskItemBg',
+ 'RewardTop1',
+ 'RewardTop2',
+ 'RewardTop3',
+ 'ruleActivity',
+ 'giftBg',
+ 'RankingItem',
+ 'top1',
+ 'top2',
+ 'top3',
+ ],
background: [
'barrageBg',
'cardBackBg',
diff --git a/src/views/Activities/GulbenFestival/页面创建信息.md b/src/views/Activities/GulbenFestival/页面创建信息.md
new file mode 100644
index 0000000..7ebe4d7
--- /dev/null
+++ b/src/views/Activities/GulbenFestival/页面创建信息.md
@@ -0,0 +1,1102 @@
+# 页面创建信息
+
+## 1. 基础信息
+
+- 页面类型:Activities
+- 页面名称:Gulben Festival
+- 页面目录:`src/views/Activities/GulbenFestival`
+- 参考页面:未指定
+- 备注:`页面创建信息.md` 是 `page.config.js` 和 `index.vue` 的生成来源;`index.vue` 先生成可预览底座,后续再按指定模块逐步补结构和接口。正式图片、接口、活动时间待补充。
+
+## 2. 路由和权限
+
+- route path:`/activities/gulben-festival`
+- route name:`gulben-festival`
+- 是否需要登录:是
+- 是否加入 `src/config/security.js`:是
+- 是否加入 `src/utils/permissionManager.js`:是
+
+## 3. OSS 图片目录
+
+- OSS 相对路径:`Activities/GulbenFestival/`
+- 本地开发目录:`public/oss/h5/Azizi/Activities/GulbenFestival/`
+- 是否需要 Codex 自动创建本地开发目录:是
+- 图片是否已放入本地目录:是
+
+### 3.1 多语言图片清单
+
+| 图片名 | 支持语言 | 备注 |
+| --- | --- | --- |
+| barrageBg | en | 弹幕背景 |
+| bg | en | 页面主背景 |
+| cardBackBg | en | 卡牌背面背景 |
+| collectGameBg | en | 集卡玩法背景 |
+| draw5BtBg | en | 十连抽按钮背景 |
+| draw1Bt | en | 单抽按钮 |
+| GameDrawBtBg | en | 集卡玩法抽奖按钮背景 |
+| gameTitleR | en | R 卡标题 |
+| gameTitleSR | en | SR 卡标题 |
+| gameTitleSSR | en | SSR 卡标题 |
+| giftBg | en | 奖励礼物背景 |
+| Gameticket | en | 任务奖励票券图 |
+| helpBt | en | 帮助按钮 |
+| leftBt | en | 左侧切换按钮 |
+| lotteryBg | en | 抽奖区域背景 |
+| lotteryPrizeActiveBg | en | 抽奖奖品选中背景 |
+| lotteryPrizeBg | en | 抽奖奖品默认背景 |
+| moduleActiveBg | en | 二级标签选中背景图 |
+| moduleBg | en | 二级标签默认背景图 |
+| RankingItem | en | 排行榜列表项背景 |
+| RewardTop1 | en | Personal Rank / Reward 第一套奖励背景 |
+| RewardTop2 | en | Personal Rank / Reward 第二套奖励背景 |
+| RewardTop3 | en | Personal Rank / Reward 第三套奖励背景 |
+| redeemRGift | en | R 礼物兑换奖励图 |
+| redeemRGoat | en | R 山羊兑换奖励图 |
+| redeemRLamp | en | R 灯兑换奖励图 |
+| redeemRLight | en | R 灯光兑换奖励图 |
+| redeemRMoon | en | R 月亮兑换奖励图 |
+| redeemR2001 | en | R 卡包 2001 卡片正面图 |
+| redeemR2002 | en | R 卡包 2002 卡片正面图 |
+| redeemR2003 | en | R 卡包 2003 卡片正面图 |
+| redeemR2004 | en | R 卡包 2004 卡片正面图 |
+| redeemR2005 | en | R 卡包 2005 卡片正面图 |
+| redeemSR2011 | en | SR 卡包 2011 卡片正面图 |
+| redeemSR2012 | en | SR 卡包 2012 卡片正面图 |
+| redeemSR2013 | en | SR 卡包 2013 卡片正面图 |
+| redeemSRLamb | en | SR 羊羔兑换奖励图 |
+| redeemSRScroll | en | SR 卷轴兑换奖励图 |
+| redeemSRTurkey | en | SR 火鸡兑换奖励图 |
+| redeemSSR2021 | en | SSR 卡包 2021 卡片正面图 |
+| redeemSSR2022 | en | SSR 卡包 2022 卡片正面图 |
+| redeemSSRGoat | en | SSR 山羊兑换奖励图 |
+| redeemSSRLamb | en | SSR 羊羔兑换奖励图 |
+| ruleActivity | en | 活动规则说明图 |
+| ruleBtBg | en | 规则按钮背景 |
+| ruleInfoBg | en | 规则弹窗背景 |
+| tagActiveBg | en | 一级标签选中背景图 |
+| tagBg | en | 一级标签默认背景图 |
+| taskBtGo | en | 任务去完成按钮 |
+| taskBtReceive | en | 任务领取按钮 |
+| taskBtReceived | en | 任务已领取按钮 |
+| taskItemBg | en | 任务列表项背景 |
+| ticketHandleBtBg | en | 抽奖券操作按钮背景 |
+| ticketR | en | R 抽奖券图标 |
+| ticketSR | en | SR 抽奖券图标 |
+| ticketSSR | en | SSR 抽奖券图标 |
+| timeBg | en | 活动时间背景 |
+| top1 | en | 第一名装饰 |
+| top2 | en | 第二名装饰 |
+| top3 | en | 第三名装饰 |
+| vip1 | en | VIP 等级占位图 |
+| userLevel1Bg | en | 用户等级占位背景 |
+| wealthLevel1Bg | en | 财富等级占位背景 |
+
+### 3.2 进入页面前同步预加载图片
+
+| 图片名 | 是否包含多语言 | 备注 |
+| --- | --- | --- |
+| bg | 否 | 页面主背景 |
+| timeBg | 否 | 活动时间背景 |
+| tagActiveBg | 否 | 一级标签首屏使用 |
+| tagBg | 否 | 一级标签首屏使用 |
+| lotteryBg | 否 | 默认标签 CollectCards toWin Prizes 第一个 container 大背景 |
+| collectGameBg | 否 | 默认标签 CollectCards toWin Prizes 第二个 container 大背景 |
+| ruleBtBg | 否 | 首屏规则按钮背景 |
+
+### 3.3 进入页面后后台预加载图片
+
+| 图片名 | 是否包含多语言 | 备注 |
+| --- | --- | --- |
+| barrageBg | 否 | 弹幕区域 |
+| cardBackBg | 否 | 默认标签卡牌区域 |
+| draw5BtBg | 否 | 抽奖按钮 |
+| draw1Bt | 否 | 抽奖按钮 |
+| GameDrawBtBg | 否 | 集卡玩法抽奖按钮 |
+| Gameticket | 否 | 任务奖励票券 |
+| gameTitleR | 否 | 集卡标题 |
+| gameTitleSR | 否 | 集卡标题 |
+| gameTitleSSR | 否 | 集卡标题 |
+| giftBg | 否 | 奖励内容 |
+| leftBt | 否 | 切换按钮 |
+| lotteryPrizeActiveBg | 否 | 抽奖奖品选中态 |
+| lotteryPrizeBg | 否 | 抽奖奖品默认态 |
+| moduleActiveBg | 否 | 二级标签切换时使用 |
+| moduleBg | 否 | 二级标签切换时使用 |
+| RankingItem | 否 | 排行榜列表项和我的排名背景 |
+| RewardTop1 | 否 | Personal Rank / Reward 第一套奖励背景 |
+| RewardTop2 | 否 | Personal Rank / Reward 第二套奖励背景 |
+| RewardTop3 | 否 | Personal Rank / Reward 第三套奖励背景 |
+| redeemRGift | 否 | 奖励兑换图 |
+| redeemRGoat | 否 | 奖励兑换图 |
+| redeemRLamp | 否 | 奖励兑换图 |
+| redeemRLight | 否 | 奖励兑换图 |
+| redeemRMoon | 否 | 奖励兑换图 |
+| redeemR2001 | 否 | R 卡包 2001 卡片正面图 |
+| redeemR2002 | 否 | R 卡包 2002 卡片正面图 |
+| redeemR2003 | 否 | R 卡包 2003 卡片正面图 |
+| redeemR2004 | 否 | R 卡包 2004 卡片正面图 |
+| redeemR2005 | 否 | R 卡包 2005 卡片正面图 |
+| redeemSR2011 | 否 | SR 卡包 2011 卡片正面图 |
+| redeemSR2012 | 否 | SR 卡包 2012 卡片正面图 |
+| redeemSR2013 | 否 | SR 卡包 2013 卡片正面图 |
+| redeemSRLamb | 否 | 奖励兑换图 |
+| redeemSRScroll | 否 | 奖励兑换图 |
+| redeemSRTurkey | 否 | 奖励兑换图 |
+| redeemSSR2021 | 否 | SSR 卡包 2021 卡片正面图 |
+| redeemSSR2022 | 否 | SSR 卡包 2022 卡片正面图 |
+| redeemSSRGoat | 否 | 奖励兑换图 |
+| redeemSSRLamb | 否 | 奖励兑换图 |
+| ruleActivity | 否 | 规则内容 |
+| ruleInfoBg | 否 | 规则弹窗背景 |
+| taskBtGo | 否 | 任务按钮 |
+| taskBtReceive | 否 | 任务按钮 |
+| taskBtReceived | 否 | 任务按钮 |
+| taskItemBg | 否 | 任务列表项 |
+| ticketHandleBtBg | 否 | 抽奖券操作按钮 |
+| ticketR | 否 | 抽奖券图标 |
+| ticketSR | 否 | 抽奖券图标 |
+| ticketSSR | 否 | 抽奖券图标 |
+| top1 | 否 | 排行榜前三名 |
+| top2 | 否 | 排行榜前三名 |
+| top3 | 否 | 排行榜前三名 |
+| vip1 | 否 | 排行榜用户等级展示 |
+| userLevel1Bg | 否 | 排行榜用户等级展示 |
+| wealthLevel1Bg | 否 | 排行榜用户等级展示 |
+
+## 4. 模块配置
+
+说明:这里的配置会生成 `page.config.js` 中的 `modules`,并驱动后续 `index.vue` 初版结构。
+
+```json
+{
+ "tabs": {
+ "enabled": true,
+ "defaultValue": "CollectCards toWin Prizes",
+ "items": [
+ "CollectCards toWin Prizes",
+ "Personal Rank",
+ "Room Rank"
+ ]
+ },
+ "sections": [
+ {
+ "name": "collectCards",
+ "enabled": true,
+ "apis": [],
+ "images": [
+ "tagActiveBg",
+ "tagBg"
+ ],
+ "remark": "一级标签内容区;图片为背景图,使用 itemCenter 组件居中换行展示英文标题文本,开发期间不加翻译",
+ "children": {}
+ },
+ {
+ "name": "personalRank",
+ "enabled": true,
+ "apis": [],
+ "images": [
+ "tagActiveBg",
+ "tagBg"
+ ],
+ "remark": "一级标签内容区;图片为背景图,使用 itemCenter 组件居中换行展示英文标题文本,开发期间不加翻译",
+ "children": {
+ "tabs": {
+ "enabled": true,
+ "defaultValue": "Reward",
+ "items": [
+ "Reward",
+ "Ranking"
+ ]
+ },
+ "sections": [
+ {
+ "name": "Reward",
+ "enabled": true,
+ "apis": [],
+ "images": [
+ "moduleActiveBg",
+ "moduleBg"
+ ],
+ "remark": "二级奖励标签内容区;图片为背景图,使用 itemCenter 组件居中换行展示英文标题文本,开发期间不加翻译",
+ "children": {}
+ },
+ {
+ "name": "Ranking",
+ "enabled": true,
+ "apis": [],
+ "images": [
+ "moduleActiveBg",
+ "moduleBg"
+ ],
+ "remark": "二级排行榜标签内容区;图片为背景图,使用 itemCenter 组件居中换行展示英文标题文本,开发期间不加翻译",
+ "children": {}
+ }
+ ]
+ }
+ },
+ {
+ "name": "roomRank",
+ "enabled": true,
+ "apis": [],
+ "images": [
+ "tagActiveBg",
+ "tagBg"
+ ],
+ "remark": "一级标签内容区;图片为背景图,使用 itemCenter 组件居中换行展示英文标题文本,开发期间不加翻译",
+ "children": {
+ "tabs": {
+ "enabled": true,
+ "defaultValue": "Reward",
+ "items": [
+ "Reward",
+ "Ranking"
+ ]
+ },
+ "sections": [
+ {
+ "name": "Reward",
+ "enabled": true,
+ "apis": [],
+ "images": [
+ "moduleActiveBg",
+ "moduleBg"
+ ],
+ "remark": "二级奖励标签内容区;图片为背景图,使用 itemCenter 组件居中换行展示英文标题文本,开发期间不加翻译",
+ "children": {}
+ },
+ {
+ "name": "Ranking",
+ "enabled": true,
+ "apis": [],
+ "images": [
+ "moduleActiveBg",
+ "moduleBg"
+ ],
+ "remark": "二级排行榜标签内容区;图片为背景图,使用 itemCenter 组件居中换行展示英文标题文本,开发期间不加翻译",
+ "children": {}
+ }
+ ]
+ }
+ }
+ ]
+}
+```
+
+## 5. 模块信息待填写
+
+说明:下面根据第 4 节 tabs / sections 自动拆出填写坑位。你只需要补已确定的接口、字段、文案和交互规则;没填的初版页面先跳过或做静态占位。
+
+生成说明:先生成可预览底座,只包含预加载、组件引用、图片工具、页面背景、顶部基础区域、一级/二级 tabs 容器和空内容占位。下面的业务模块和接口等你后续逐步指定后再生成。
+
+### 5.1 全局顶部和规则入口
+
+```json
+{
+ "enabled": true,
+ "activityTime": "",
+ "images": ["bg", "ruleBtBg", "ruleInfoBg", "ruleActivity"],
+ "apis": [],
+ "fields": {
+ "startTime": "",
+ "endTime": "2026-06-01 05:00:00"
+ },
+ "actions": {
+ "rule": "点击规则按钮图片打开规则弹窗"
+ },
+ "popup": {
+ "component": "MaskLayer",
+ "backgroundImage": "ruleInfoBg",
+ "contentComponent": "itemCenter",
+ "closeRule": "点击遮罩关闭,点击弹窗内容阻止冒泡",
+ "contentStyle": "padding:18vw 7vw 12vw; align-items:stretch; justify-content:flex-start; box-sizing:border-box",
+ "textStyle": "max-height:62vh; overflow-y:auto; color:#FFF; font-size:1em; font-weight:600; line-height:1.35; text-align:left",
+ "scrollbar": "内容区可上下滑动,但使用 rule-popup-scroll 隐藏滚动条;需要 class 处理 scrollbar-width / -ms-overflow-style / ::-webkit-scrollbar",
+ "renderRule": "规则文案不要依赖源码空白或 white-space:pre-line;按标题和 (1)/(2)/(3) 拆成独立 div 行,保证 1、2、3、4 和每条小规则都单独换行",
+ "content": "1、How to Earn Tickets:\n(1)Complete designated series of tasks to earnTickets.\n2、How to Use Tickets:\n(1)Each Ticket can be used to randomly drawone R-grade Card.\n3、Card Combining& Upgrade:\n(1)Combine 5 different R-grade Cards to randomlydraw one SR-grade Card.\n(2)Combine 3 different SR-grade Cards torandomly draw one SSR-grade Card.\n4、Collect & Redeem Rewards:\n(1)Collect 5 different R-grade Cards to redeem onechance to draw a Standard Reward.\n(2)Collect 3 different SR-grade Cards to redeemone chance to draw a Premium Reward.\n(3)Collect 2 different SSR-grade Cards to redeemone chance to draw a Deluxe Reward!"
+ },
+ "remark": "规则弹窗用 src/components/MaskLayer.vue,写法参考 src/views/Activities/LuckyDollars/Season5/index.vue。"
+}
+```
+
+### 5.2 倒计时模块
+
+```json
+{
+ "name": "countdown",
+ "enabled": "",
+ "deadline": "",
+ "images": ["timeBg"],
+ "fields": {
+ "endTime": "2026-06-01 05:00:00",
+ "remainingText": ""
+ },
+ "remark": "倒计时方法参考 src/views/Activities/LuckyDollars/Season5/index.vue:使用 Days / Hours / Minutes / Seconds 四个 ref,calculateCountdown 立即计算并由 startCountdown 每秒刷新;天数在模板补 D,时分秒补两位;目标时间直接在 getTargetTime 中使用 new Date(2026, 5, 1, 5, 0, 0),不解析中文;时分秒的中间用英文冒号:"
+}
+```
+
+### 5.3 一级标签:CollectCards toWin Prizes
+
+```json
+{
+ "sectionName": "collectCards",
+ "tabText": "CollectCards toWin Prizes",
+ "enabled": true,
+ "apis": {
+ "activityDetail": "activityDetail(activityCode):参考 LuckyDollars Season5 的 /activity/lottery/detail/${activityCode}",
+ "rewardDraw": "apiPostCardRewardDraw({ activityId:'1001', ticketType, drawCount }):POST /activity/card/reward/draw;lotteryBg 下层 draw1Bt / draw5BtBg 使用,ticketType 为 STANDARD/PREMIUM/LUXURY,对应 R/SR/SSR 奖池,drawCount 为 integer 格式的 1 或 5;请求成功后刷新 apiGetCardTicketList('1001')",
+ "drawOnce": "apiPostCardDraw({ activityId:'1001', drawCount, needCoins }):POST /activity/card/draw;点击 collectGameBg 的 GameDrawBtBg 时调用,drawCount 默认为 1;当 CARD 抽卡票数量为 0 时 needCoins=true,否则 needCoins=false",
+ "drawFive": "",
+ "combineCard": "apiPostCardCombine({ activityId:'1001', cardTemplateIds }):POST /activity/card/combine;R 奖池需要 5 个不同 R 卡 templateId 合成 1 张 SR,SR 奖池需要 3 个不同 SR 卡 templateId 合成 1 张 SSR;SSR 奖池不展示 Combine",
+ "redeemCard": "apiPostCardRedeem({ activityId:'1001', cardTemplateIds }):POST /activity/card/redeem;将当前奖池不同卡片兑换为对应奖励票,R 需要 5 个不同 R 卡、SR 需要 3 个不同 SR 卡、SSR 需要 2 个不同 SSR 卡;成功后刷新票券数量和当前卡包",
+ "ticketList": "apiGetCardTicketList('1001'):GET /activity/card/ticket/list?activityId=1001",
+ "rewardPrizeList": "apiGetCardRewardPrizeList('1001', poolType):GET /activity/card/reward/prize/list?activityId=1001&poolType=${poolType};初始化时按 STANDARD / PREMIUM / LUXURY 三个 poolType 分别调用,对应页面展示 R / SR / SSR",
+ "rewardMarquee": "apiGetCardRewardMarquee('1001'):GET /activity/card/reward/marquee?activityId=1001",
+ "cardBagList": "apiPostCardBagList({ activityId:'1001', cardGrade, current:1, size:20 }):POST /activity/card/bag/list;初始化时按 R / SR / SSR 三个 cardGrade 分别调用,接口虽然是 POST 但用于查询用户卡包返回数据",
+ "taskList": "",
+ "receiveTaskReward": "",
+ "winnerHistory": ""
+ },
+ "images": {
+ "container": ["lotteryBg", "collectGameBg", "taskModule"],
+ "lottery": [ "lotteryPrizeBg", "lotteryPrizeActiveBg", "draw1Bt", "draw5BtBg"],
+ "collectGame": ["cardBackBg", "GameDrawBtBg", "redeemR2001", "redeemR2002", "redeemR2003", "redeemR2004", "redeemR2005", "redeemSR2011", "redeemSR2012", "redeemSR2013", "redeemSSR2021", "redeemSSR2022"],
+ "tickets": ["ticketR", "ticketSR", "ticketSSR", "ticketHandleBtBg", "helpBt"],
+ "titles": ["gameTitleR", "gameTitleSR", "gameTitleSSR"],
+ "tasks": ["taskItemBg", "taskBtGo", "taskBtReceive", "taskBtReceived", "Gameticket"],
+ "ranking": ["RankingItem", "vip1", "userLevel1Bg", "wealthLevel1Bg"],
+ "barrage": ["barrageBg"]
+ },
+ "fields": {
+ "ticketCount": "",
+ "cardBagPools": "按 cardGrade 存储 /activity/card/bag/list 返回的 body.records;R/SR/SSR 分别对应集卡奖池的卡片正面展示",
+ "cardList": "",
+ "lotteryPrizeList": "",
+ "taskList": "",
+ "barrageList": ""
+ },
+ "actions": {
+ "rewardDraw": "点击 lotteryBg 下层 draw1Bt 调用 apiPostCardRewardDraw({ activityId:'1001', ticketType:当前奖池 poolType, drawCount:1 }),当前奖池票券数量为 0 时按钮置灰且点击无效;点击 draw5BtBg 调用 drawCount:5,当前奖池票券数量不足 5 时按钮置灰且点击无效;接口成功后刷新 apiGetCardTicketList('1001') 同步第一层三种票券数量,并保留当前 lotteryPrizeBg / lotteryPrizeActiveBg 转圈动画",
+ "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 卡;成功后刷新当前等级卡包和目标等级卡包",
+ "redeemCard": "点击 Redeem 调用 apiPostCardRedeem({ activityId:'1001', cardTemplateIds });cardTemplateIds 为当前奖池用户已拥有且数量大于 0 的不同卡片 templateId 字符串数组;R/SR/SSR 分别需要 5/3/2 个不同卡片;成功后刷新 apiGetCardTicketList('1001') 和当前等级卡包",
+ "goTask": "",
+ "receiveTaskReward": ""
+ },
+ "layout": {
+ "containerRender": "container 大背景图使用 itemCenter 渲染",
+ "containerStyle": "width:100vw; margin:0; padding:0",
+ "wrapperStyle": "display:flex; flex-direction:column; align-items:center; gap:0; margin:0; padding:0",
+ "contentStyle": "padding:0",
+ "lotteryBg": {
+ "component": "itemCenter",
+ "contentStyle": "padding:8vw 5vw 0; align-items:stretch; justify-content:flex-start; box-sizing:border-box",
+ "innerStyle": "width:100%; height:100%; display:flex; flex-direction:column; gap:0",
+ "topLayer": {
+ "style": "display:flex; flex:1; align-items:center; gap:4vw",
+ "sideWidth": "10vw",
+ "leftButton": "leftBt",
+ "rightButton": "leftBt 水平翻转",
+ "buttonImageStyle": "width:8vw; display:block"
+ },
+ "prizes": {
+ "modes": {
+ "lotteryR": {
+ "count": 7,
+ "rowCounts": [2, 3, 2],
+ "ticketImage": "ticketR",
+ "remark": "R 奖池当前使用 7 个测试奖品,按上层 2 个、中间 3 个、下层 2 个渲染;后续由接口返回数组替换测试数据"
+ },
+ "lotterySR": {
+ "count": 3,
+ "rowCounts": [3],
+ "ticketImage": "ticketSR",
+ "listClass": "lottery-prize-list-large",
+ "itemClass": "lottery-prize-item-sr",
+ "remark": "SR 奖池当前使用 3 个测试奖品,一行居中展示 3 个;通过缩小奖品间距和单独奖品 class 放大奖品背景图;后续由接口返回数组替换测试数据"
+ },
+ "lotterySSR": {
+ "count": 2,
+ "rowCounts": [2],
+ "ticketImage": "ticketSSR",
+ "listClass": "lottery-prize-list-large",
+ "itemClass": "lottery-prize-item-ssr",
+ "remark": "SSR 奖池当前使用 2 个测试奖品,一行居中展示 2 个;使用比 SR 更大的奖品宽度;后续由接口返回数组替换测试数据"
+ }
+ },
+ "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;抽奖动画执行中暂不允许切换奖池",
+ "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",
+ "remark": "奖品区域按行渲染,每一行使用 flex 居中,避免 grid 在最后一行数量不足时贴左;没有接口数据时保留本地测试奖品占位,方便预览。"
+ },
+ "animation": {
+ "reference": "/activities/lucky-dollars-season5",
+ "mode": "切换奖品 itemCenter 的背景图来模拟转圈,而不是叠加 prizeActiveFrame 发光边框",
+ "activeRule": "同一时间只有一个奖品 itemCenter 使用 lotteryPrizeActiveBg,其余使用 lotteryPrizeBg",
+ "order": "从左到右、从上到下依次变化背景图",
+ "resultFlow": "点击抽奖后先请求接口,接口返回中奖奖品,再根据奖品 id/name 计算最终位置,然后启动动画并停在中奖位置",
+ "multiDraw": "5 连抽也只执行一次动画,最终位置取接口返回结果中的第一个中奖奖品",
+ "currentPreview": "requestLotteryDraw 已接入 /activity/card/reward/draw;接口结果暂未映射具体中奖奖品位置时,动画仍优先停在当前奖池第 4 个奖品(数组下标 3),如果当前奖池不足 4 个,则停在最后一个奖品;后续根据接口返回 prizeId/name 再补最终位置映射",
+ "performanceNote": "当前奖品数量少,直接切换两个 itemCenter 的 imgUrl 可以满足效果;后续如果奖品数量增加、动画频率提高或低端机出现闪烁/卡顿,优先考虑改成固定默认背景 + 单个 active 覆盖层,或用 class 切换预加载后的 CSS background,以减少图片 src 变更。",
+ "decisionRule": "只要满足效果和布局,优先选择更省性能、用户体验更稳定的方案;如果 Codex 发现更好的样式或动画实现,需要先说明方案、优缺点和影响范围,并询问是否采用,不能静默替换用户已调好的结构。"
+ },
+ "bottomLayer": {
+ "style": "display:flex; align-items:center; justify-content:space-around; gap:0; margin:0; padding:0",
+ "ticketIcon": "两个抽奖按钮内部都放当前奖池 ticketImage,图片宽度 10vw;itemCenter contentStyle 使用 padding:0; align-items:flex-end; 保持水平居中并垂直贴底;点击左右切换 lotteryR / lotterySR / lotterySSR 时同步切换 ticketR / ticketSR / ticketSSR",
+ "buttons": [
+ {
+ "image": "draw1Bt",
+ "style": "width:38vw; margin:0; padding:0",
+ "contentStyle": "padding:0; align-items:flex-end",
+ "enabledRule": "当前奖池 poolType 对应的 STANDARD/PREMIUM/LUXURY 票券数量 >= 1 才可点击,否则添加 collect-action-disabled 置灰且点击无效",
+ "action": "点击调用 apiPostCardRewardDraw({ activityId:'1001', ticketType:当前奖池 poolType, drawCount:1 });成功后刷新 ticketList"
+ },
+ {
+ "image": "draw5BtBg",
+ "style": "width:38vw; margin:0; padding:0",
+ "contentStyle": "padding:0; align-items:flex-end",
+ "enabledRule": "当前奖池 poolType 对应的 STANDARD/PREMIUM/LUXURY 票券数量 >= 5 才可点击,否则添加 collect-action-disabled 置灰且点击无效",
+ "action": "点击调用 apiPostCardRewardDraw({ activityId:'1001', ticketType:当前奖池 poolType, drawCount:5 });成功后刷新 ticketList"
+ }
+ ]
+ }
+ },
+ "collectGameBg": {
+ "component": "itemCenter",
+ "contentStyle": "padding:0; align-items:stretch; justify-content:flex-start; box-sizing:border-box",
+ "innerStyle": "width:100%; height:100%; display:flex; flex-direction:column; gap:0; margin:0; padding:0",
+ "ticketLayer": {
+ "style": "height:10vw; display:flex; align-items:center; justify-content:center; gap:2vw; padding:0; margin:0",
+ "items": [
+ { "image": "ticketR", "ticketType": "STANDARD" },
+ { "image": "ticketSR", "ticketType": "PREMIUM" },
+ { "image": "ticketSSR", "ticketType": "LUXURY" }
+ ],
+ "itemStyle": "flex:1; min-width:0; height:100%; display:flex; align-items:center; justify-content:center; gap:1vw",
+ "help": "第一个单位内贴顶放 2vw 宽度 helpBt",
+ "ticketImageStyle": "width:10vw; display:block",
+ "text": ":remainingCount,默认 0",
+ "textStyle": "color:#fff; font-size:1em; font-weight:700"
+ },
+ "tipLayer": {
+ "text": "*Redeem Your Cards and Draw for Amazing Rewards!",
+ "style": "color:#D2D2D2; font-size:1em; font-weight:600; text-align:center; padding:0; margin:0"
+ },
+ "titleLayer": {
+ "height": "10vw",
+ "titles": {
+ "gameR": "gameTitleR",
+ "gameSR": "gameTitleSR",
+ "gameSSR": "gameTitleSSR"
+ },
+ "imageStyle": "height:10vw; width:auto; display:block"
+ },
+ "prizeLayer": {
+ "modes": {
+ "gameR": 5,
+ "gameSR": 3,
+ "gameSSR": 2
+ },
+ "sideWidth": "10vw",
+ "leftButton": "leftBt",
+ "rightButton": "leftBt 水平翻转",
+ "centerStyle": "flex:1; min-width:0; height:20vw; display:flex; align-items:center; justify-content:space-around; gap:0",
+ "cardImage": "cardBackBg",
+ "cardStyle": "height:100%; width:auto; display:block",
+ "cardBagApi": {
+ "endpoint": "POST /activity/card/bag/list",
+ "params": {
+ "activityId": "'1001'",
+ "cardGrade": "R / SR / SSR",
+ "current": 1,
+ "size": 20
+ },
+ "responseRecords": "body.records[];字段包含 cardTemplateId、cardGrade、cardName、cardImage、quantity",
+ "imageMap": {
+ "2001": "redeemR2001",
+ "2002": "redeemR2002",
+ "2003": "redeemR2003",
+ "2004": "redeemR2004",
+ "2005": "redeemR2005",
+ "2011": "redeemSR2011",
+ "2012": "redeemSR2012",
+ "2013": "redeemSR2013",
+ "2021": "redeemSSR2021",
+ "2022": "redeemSSR2022"
+ },
+ "renderRule": "卡位固定渲染,R 固定 5 张、SR 固定 3 张、SSR 固定 2 张;接口 records 中存在对应 cardTemplateId 时,将该卡位从 cardBackBg 替换为映射的本地 redeem 图片;未返回或未映射的卡位继续展示 cardBackBg,避免接口数据数量变化导致布局跳动。",
+ "quantityBadge": "除 cardBackBg 之外的卡片正面图片都使用 itemCenter 组件承载,组件 style 使用 height:100%; width:auto; aspect-ratio:159/261; flex:0 0 auto;内部绝对定位右上角展示 records.quantity,角标样式为 border-radius:33px 33px 33px 34px; background:rgba(0,0,0,0.10); backdrop-filter:blur(2px); padding:4px; color:#fff;cardBackBg 仍然使用普通 img,不展示数量角标。"
+ },
+ "remark": "点击左右按钮切换 gameR / gameSR / gameSSR;第三层标题、第四层卡片正反面和第五层操作层跟随当前模式。"
+ },
+ "actionLayer": {
+ "style": "display:flex; align-items:center; justify-content:space-around; gap:0; padding:0; margin:0",
+ "buttons": [
+ {
+ "image": "GameDrawBtBg",
+ "style": "width:40vw; margin:0; padding:0",
+ "contentStyle": "padding:0; flex-direction:column; gap:0.5vw; align-items:center; justify-content:center",
+ "content": ["Remaining Tickets: *CARD.remainingCount", "1000/coins"],
+ "action": "点击调用 /activity/card/draw,data 为 { activityId:'1001', drawCount, needCoins },drawCount 默认为 1;当 CARD.remainingCount 为 0 时 needCoins=true,否则 needCoins=false;请求成功后调用 ticketList 刷新 CARD.remainingCount,并只刷新 R 级 cardBagList 更新当前 R 卡 quantity"
+ },
+ {
+ "image": "ticketHandleBtBg",
+ "style": "width:25vw; margin:0; padding:0",
+ "contentStyle": "padding:0; align-items:center; justify-content:center",
+ "content": ["Combine * 1"],
+ "visibleRule": "SSR 奖池不展示 Combine",
+ "enabledRule": "R 奖池用户需要拥有 5 个不同 R 卡,SR 奖池用户需要拥有 3 个不同 SR 卡;不同卡片按当前奖池固定 cardTemplateIds 中 quantity > 0 的卡片计算,不满足时添加 collect-action-disabled 样式置灰且点击无效",
+ "action": "点击调用 /activity/card/combine,data 为 { activityId:'1001', cardTemplateIds },cardTemplateIds 为字符串数组;R 合成成功后刷新 R 和 SR 卡包,SR 合成成功后刷新 SR 和 SSR 卡包"
+ },
+ {
+ "image": "ticketHandleBtBg",
+ "style": "width:25vw; margin:0; padding:0",
+ "contentStyle": "padding:0; flex-direction:column; gap:0.5vw; align-items:center; justify-content:center",
+ "content": ["Redeem", "当前奖池票券图(ticketR/ticketSR/ticketSSR) + *1"],
+ "enabledRule": "R/SR/SSR 奖池分别需要拥有 5/3/2 个不同卡片;不同卡片按当前奖池固定 cardTemplateIds 中 quantity > 0 的卡片计算,不满足时添加 collect-action-disabled 样式置灰且点击无效",
+ "action": "点击调用 /activity/card/redeem,data 为 { activityId:'1001', cardTemplateIds },cardTemplateIds 为字符串数组;成功后立即刷新 ticketList 和当前等级 cardBagList,使第一层三种票券数量和当前卡片 quantity 同步更新"
+ }
+ ]
+ }
+ ,
+ "taskModule": {
+ "component": "itemCenter",
+ "itemImage": "taskItemBg",
+ "source": "任务接口和字段先参考 src/views/Activities/LuckyDollars/Season5/index.vue 的 taskList、ActTaskList、receiveTickets、showTarget",
+ "data": "当前 Gulben 页面固定 activityId=1001,任务列表调用 ActTaskList('1001'),领取调用 receiveTickets({ activityId:'1001', taskCode })",
+ "wrapperStyle": "width:100vw; display:flex; flex-direction:column; gap:2vw; margin:0; padding:0 5vw; box-sizing:border-box",
+ "itemStyle": "width:100%; margin:0; padding:0",
+ "contentStyle": "display:flex; padding:8px; justify-content:space-between; align-items:center",
+ "layout": "v-for=\"task in taskList\";左侧展示 showTarget(task) 和 Rewards:*rewardValue;右侧使用 itemCenter 展示 taskBtGo / taskBtReceive / taskBtReceived,并显示 t('task_go') / t('task_receive') / t('task_received');不展示 Season5 中的任务类型图标和票券图标",
+ "actions": "taskStatus === 1 时点击 taskBtReceive 的 itemCenter 调用 receiveTaskReward(task.taskCode)"
+ }
+ }
+ },
+ "remark": "补集卡/抽奖/任务的数据来源和按钮行为。CollectCards toWin Prizes 当前从上到下拆成 lotteryBg、collectGameBg 两个 container 大背景模块;初始不加边框虚线,padding、margin、gap 先设为 0,后续再按预览微调。"
+}
+```
+
+### 5.4 一级标签:Personal Rank
+
+```json
+{
+ "sectionName": "personalRank",
+ "tabText": "Personal Rank",
+ "enabled": true,
+ "children": {
+ "Reward": {
+ "enabled": true,
+ "apis": {
+ "rewardConfig": "apiGetActivityResource:/props-activity-cnf/client/listActivityResource?sysOrigin=LIKEI&activityType=${rewardActivityType}",
+ "receiveReward": ""
+ },
+ "images": {
+ "tab": ["moduleBg", "moduleActiveBg"],
+ "rewardBg": ["RewardTop1", "RewardTop2", "RewardTop3"],
+ "rewardItems": ["redeemRGift", "redeemRGoat", "redeemRLamp", "redeemRLight", "redeemRMoon", "redeemSRLamb", "redeemSRScroll", "redeemSRTurkey", "redeemSSRGoat", "redeemSSRLamb"]
+ },
+ "fields": {
+ "rewardList": "apiGetActivityResource 返回数组的前 3 套;每套 propsGroup.activityRewardProps 最多取 6 个",
+ "receiveStatus": "",
+ "threshold": ""
+ },
+ "actions": {
+ "receiveReward": ""
+ },
+ "layout": {
+ "container": "Personal Rank / Reward 标签只展示 RewardTop1、RewardTop2、RewardTop3 三个 itemCenter,宽度均为 100vw,从上往下排序",
+ "source": "布局模仿 src/views/Activities/DailyRecharge/index.vue 的奖励展示模块:左侧一个主奖励,右侧网格奖励",
+ "dataRule": "接口返回 10 套奖励时只取前 3 套;每套最多 6 个奖励,第 1 个作为左侧大图,后 5 个放右侧;如果某套只有 5 个奖励,则复制第 5 个奖励补到第 6 个位置用于预览铺满右侧第三行",
+ "rightGrid": "右侧奖励固定分 3 行,前两行每行 2 个,第三行使用单个展示位宽度并居中;即使第三行没有奖励,也保留一个不可见占位,避免模块高度和居中位置跳动",
+ "display": "奖励图使用 reward.cover,图片错误处理使用 handleRewardImageError;奖励文案统一使用 formatRewardDisplay(reward.type, reward),规则与 DailyRecharge 保持一致,不额外优先展示 reward.content",
+ "fallback": "接口未返回时临时使用 redeemRGift / redeemRGoat / redeemRLamp / redeemRLight / redeemRMoon / redeemSRLamb 组成 3 套本地预览奖励"
+ },
+ "remark": "补个人榜奖励配置、领取条件和领取接口;当前先展示奖励配置,不接领取。"
+ },
+ "Ranking": {
+ "enabled": true,
+ "apis": {
+ "rankList": "getWeekStarRanking:/activity/week-star/ranking?type=${type}¤t=${current}&size=${size}",
+ "myRank": "getMyWeekStarRanking:/activity/week-star/my-rank?type=${type}&userId=${userId}",
+ "userInfo": "getMemberProfile:用于获取 my-rank 需要的 userId"
+ },
+ "images": {
+ "tab": ["moduleBg", "moduleActiveBg"],
+ "top": ["top1", "top2", "top3"]
+ },
+ "rankConfig": {
+ "loadMode": "remote",
+ "type": "THIS_WEEK",
+ "pageSize": 10,
+ "topCount": 3,
+ "fieldMap": {
+ "avatar": "userAvatar",
+ "name": "userNickname -> userName",
+ "value": "quantity -> amount",
+ "userId": "userId",
+ "rank": "rank"
+ }
+ },
+ "remark": "个人榜 Ranking 数据改用 /activity/week-star 的排行榜接口;首次进入加载第一页和我的排名,列表底部放 rankingLoadmore 哨兵,通过 IntersectionObserver 上拉分页,hasMore/loading 防止重复请求。"
+ }
+ }
+}
+```
+
+### 5.5 一级标签:Room Rank
+
+```json
+{
+ "sectionName": "roomRank",
+ "tabText": "Room Rank",
+ "enabled": true,
+ "children": {
+ "Reward": {
+ "enabled": true,
+ "apis": {
+ "rewardConfig": "",
+ "receiveReward": ""
+ },
+ "images": {
+ "tab": ["moduleBg", "moduleActiveBg"],
+ "rewardBg": ["ruleActivity"],
+ "rewardItems": []
+ },
+ "fields": {
+ "rewardList": "",
+ "receiveStatus": "",
+ "threshold": ""
+ },
+ "actions": {
+ "receiveReward": ""
+ },
+ "layout": {
+ "component": "img",
+ "image": "ruleActivity",
+ "style": "width:100vw; display:block",
+ "remark": "Room Rank / Reward 暂不接奖励配置,直接 100vw 展示 ruleActivity"
+ },
+ "remark": "当前按用户要求直接展示 ruleActivity;后续如果要接房间榜奖励配置再补接口。"
+ },
+ "Ranking": {
+ "enabled": true,
+ "apis": {
+ "rankList": "",
+ "myRank": ""
+ },
+ "images": {
+ "tab": ["moduleBg", "moduleActiveBg"],
+ "top": ["top1", "top2", "top3"],
+ "list": ["RankingItem"]
+ },
+ "rankConfig": {
+ "loadMode": "remote",
+ "pageSize": 10,
+ "topCount": 3,
+ "fieldMap": {
+ "avatar": "userAvatar",
+ "name": "userName / userNickname",
+ "value": "amount / quantity",
+ "roomId": "userProfile.inRoomId",
+ "rank": "rank / ranking"
+ }
+ },
+ "layout": {
+ "source": "展示结构复用 Personal Rank / Ranking 的排行榜部分",
+ "difference": "不展示 Personal Rank 的活动礼物 giftBg 模块;头像统一 border-radius:4px;第四名以后和我的排名只展示排名、头像、名称、ID、金币数量,不展示 vip1 / userLevel1Bg / wealthLevel1Bg 三个等级图片",
+ "myRanking": "切换到 Room Rank / Ranking 时同样固定贴底展示我的排名,样式跟列表一致,只保留名称、ID 和金币数量",
+ "pagination": "使用 GET /room/contribution/total-rank?startDate=20260518&endDate=20260524&size=20;当前接口没有 current/page 参数,加载完成后 rankingHasMore=false,不再触发上拉继续请求"
+ },
+ "remark": "Room Rank / Ranking 当前复用 Personal Rank 排行榜接口和布局,按房间榜展示要求裁掉礼物模块与等级图。"
+ }
+ }
+}
+```
+
+### 5.6 接口参数
+
+```json
+{
+ "activityId": "1001",
+ "activityCode": "",
+ "activityType": "",
+ "personalRankActivityType": "THIS_WEEK",
+ "roomRankActivityType": "",
+ "rewardActivityType": "DAILY_RECHARGE_TASK2",
+ "otherParams": {}
+}
+```
+
+### 5.7 组件需求
+
+```json
+[
+ {
+ "name": "BackgroundLayer",
+ "enabled": "",
+ "remark": "页面主背景 bg"
+ },
+ {
+ "name": "itemCenter",
+ "enabled": "是",
+ "remark": "一级/二级标签图片背景 + 居中文本;倒计时天/时/分/秒图片背景"
+ },
+ {
+ "name": "ActivityTabs",
+ "enabled": "",
+ "remark": "一级标签和二级标签;如不用通用组件,则按图片背景自定义"
+ },
+ {
+ "name": "ActivityLotteryGrid",
+ "enabled": "",
+ "remark": "CollectCards 抽奖区域"
+ },
+ {
+ "name": "Barrage",
+ "enabled": "",
+ "remark": "CollectCards 弹幕区域"
+ },
+ {
+ "name": "topUser / TopUser",
+ "enabled": "",
+ "remark": "个人榜/房间榜前三名展示;GulbenFestival 使用页面私有组件 src/views/Activities/GulbenFestival/components/topUser.vue,不直接修改或复用参考页面组件"
+ }
+]
+```
+
+### 5.8 当前 Vue 布局与样式同步记录
+
+说明:本节按实时 `src/views/Activities/GulbenFestival/index.vue` 反向同步,用于记录当前可预览底座已经调好的结构和样式。后续如果本轮执行方案是“md -> vue”,则以本节为源同步到 Vue;如果本轮执行方案是“vue -> md”,则以实时 Vue 为准更新本节。
+
+```json
+{
+ "syncSource": "src/views/Activities/GulbenFestival/index.vue",
+ "lastSyncDirection": "vue -> md",
+ "layout": {
+ "root": {
+ "class": "fullPage",
+ "styleBlock": "style lang=\"scss\" scoped 顶部固定放全局文字色、fullPage、响应式基础字号 media query"
+ },
+ "loading": {
+ "style": "display:flex; min-height:100vh; align-items:center; justify-content:center"
+ },
+ "pageShell": {
+ "class": "scroll",
+ "dir": "isRtlLayout ? 'rtl' : 'ltr'",
+ "style": "min-height:100vh; position:relative; overflow-x:hidden"
+ },
+ "background": {
+ "component": "BackgroundLayer",
+ "images": ["bg"],
+ "remark": "背景图内包含标题和顶部视觉信息"
+ },
+ "content": {
+ "tag": "main",
+ "style": "position:relative; z-index:1; width:min(100vw, 480px); min-height:100vh; margin:0 auto; padding:100vw 4vw 12vw",
+ "remark": "顶部 padding 预留背景标题和活动视觉信息位置"
+ }
+ },
+ "rows": [
+ {
+ "name": "rule",
+ "order": 1,
+ "wrapperClass": "rule-row",
+ "rtlRule": "[dir='rtl'] .rule-row { justify-content: flex-start; }",
+ "component": "itemCenter",
+ "image": "ruleBtBg",
+ "style": "width:15vw; flex:0 0 15vw",
+ "contentStyle": "padding: 1.5vw 2vw;",
+ "text": "rule",
+ "textStyle": "color:#fff8d7; font-size:1em; font-weight:800; line-height:1; text-align:center; text-shadow:0 1px 2px rgba(63,20,57,.5); white-space:nowrap"
+ },
+ {
+ "name": "countdown",
+ "order": 2,
+ "wrapperStyle": "display:flex; justify-content:center; margin-bottom:4vw",
+ "innerStyle": "width:min(95vw, 440px); display:flex; align-items:center; justify-content:space-between; gap:1vw; padding:0 2vw; overflow:hidden",
+ "itemClass": "countdown-cell",
+ "itemStyle": "width:min(18vw, 88px); min-width:0; flex:0 1 min(18vw, 88px)",
+ "valueClass": "countdown-value",
+ "colonClass": "countdown-colon",
+ "image": "timeBg",
+ "contentStyle": "padding: 1.8vw 1vw;",
+ "data": "参考 LuckyDollars Season5:Days / Hours / Minutes / Seconds 四个 ref;getTargetTime 直接返回 new Date(2026, 5, 1, 5, 0, 0).getTime(),月份从 0 开始,5 代表 6 月;calculateCountdown 负责计算并在过期后清零和停止定时器;startCountdown 立即执行一次并 setInterval 每秒刷新",
+ "remark": "天数在模板补 D;时分秒之间使用英文冒号"
+ },
+ {
+ "name": "topTabs",
+ "order": 3,
+ "wrapperStyle": "display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); align-items:stretch; gap:2vw; margin:0 0 4vw",
+ "buttonClass": "image-tab top-tab",
+ "activeClass": "image-tab-active",
+ "activeTextSelector": ".image-tab-active span",
+ "activeTextColor": "#5B0000",
+ "component": "itemCenter",
+ "contentStyle": "padding: 0 1vw;",
+ "images": ["tagBg", "tagActiveBg"]
+ },
+ {
+ "name": "content",
+ "order": 4,
+ "panelStyle": "min-height:86vw; padding:0; margin:0",
+ "titleStyle": "",
+ "childTabsStyle": "display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:2vw; margin-bottom:4vw",
+ "containerImages": ["lotteryBg", "collectGameBg", "taskModule"],
+ "containerWrapperStyle": "display:flex; flex-direction:column; align-items:center; gap:0; margin:0; padding:0",
+ "containerItemComponent": "itemCenter",
+ "containerItemStyle": "width:100vw; margin:0; padding:0",
+ "containerItemContentStyle": "padding:0",
+ "lotteryBgLayout": {
+ "contentStyle": "padding:8vw 5vw 0; align-items:stretch; justify-content:flex-start; box-sizing:border-box",
+ "innerStyle": "width:100%; height:100%; display:flex; flex-direction:column; gap:0",
+ "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 个时停最后一个。",
+ "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,成功后刷新第一层三种票券数量"
+ },
+ "collectGameBgLayout": {
+ "layers": 5,
+ "ticketLayer": "高度 10vw;padding:0;display:flex 居中;gap:2vw;三个单位 flex:1;第一个单位贴顶放 2vw helpBt,并展示 10vw ticketR + STANDARD.remainingCount;后两个单位展示 ticketSR / PREMIUM.remainingCount、ticketSSR / LUXURY.remainingCount;数量默认 0",
+ "tipLayer": "文本 *Redeem Your Cards and Draw for Amazing Rewards!;color:#D2D2D2;font-size:1em;font-weight:600",
+ "titleLayer": "高度 10vw;按当前 gameR / gameSR / gameSSR 展示 gameTitleR / gameTitleSR / gameTitleSSR;图片 height:10vw,width:auto",
+ "prizeLayer": "左中右布局;左右 width:10vw,内部居中放 leftBt,右侧水平翻转;中间 flex:1,高度 20vw;space-around 分布;卡位固定为 gameR 5 张、gameSR 3 张、gameSSR 2 张;初始化调用 apiPostCardBagList 分别查询 R/SR/SSR 卡包,records 中存在对应 cardTemplateId 时用 redeemR2001~redeemR2005、redeemSR2011~redeemSR2013、redeemSSR2021~redeemSSR2022 替换该卡位的 cardBackBg,否则继续显示 cardBackBg;除 cardBackBg 外的正面卡片使用 itemCenter,并在右上角绝对定位显示 quantity 数量角标",
+ "actionLayer": "使用 display:flex; justify-content:space-around。gameR 显示 40vw GameDrawBtBg、25vw Combine、25vw Redeem;gameSR 只显示 Combine 和 Redeem,水平均匀分布;gameSSR 只显示 Redeem,居中展示。GameDrawBtBg 内展示两行文本 Remaining Tickets: *CARD.remainingCount 和 1000/coins,点击调用 apiPostCardDraw({ activityId:'1001', drawCount:1, needCoins: CARD.remainingCount === 0 }),成功后刷新 CARD 抽卡票数量和 R 级卡包数量;Combine 直接展示 Combine * 1,不展示第二行,R/SR 分别需要拥有 5/3 个不同卡片才可点击,否则置灰;Redeem 保留两行展示,第二行保留当前奖池票券图(ticketR/ticketSR/ticketSSR)并固定展示 *1,R/SR/SSR 分别需要拥有 5/3/2 个不同卡片才可点击,成功后立即刷新票券和当前卡包",
+ "modeSwitch": "点击左右 leftBt 切换 gameR / gameSR / gameSSR,同时切换第三层标题、第四层卡牌数量和第五层操作层"
+ },
+ "personalRewardLayout": {
+ "section": "Personal Rank / Reward",
+ "loadRule": "切换到 Personal Rank 下的 Reward 标签时加载;调用 apiGetActivityResource(pageConfig.activity.rewardActivityType || 'DAILY_RECHARGE_TASK2'),接口返回数组只取前 3 套用于 RewardTop1 / RewardTop2 / RewardTop3",
+ "container": "三个奖励模块都使用 itemCenter,图片分别为 RewardTop1、RewardTop2、RewardTop3,宽度 100vw,从上往下排序",
+ "rewardLimit": "每套奖励最多取 propsGroup.activityRewardProps 前 6 个;第 1 个作为左侧大图奖励,后 5 个作为右侧小奖励;如果某套只有 5 个奖励,则复制第 5 个奖励补到第 6 个位置用于预览铺满右侧第三行",
+ "layout": "模仿 DailyRecharge 奖励展示:整体左右布局,左侧大图奖励,右侧小奖励固定分三行;前两行每行 2 个,第三行 1 个展示位宽度并居中;第三行没有奖励时也保留不可见占位",
+ "display": "奖励图使用 reward.cover;文案统一使用 formatRewardDisplay(reward.type, reward),不额外优先展示 reward.content;图片错误处理用 handleRewardImageError"
+ },
+ "personalRankingLayout": {
+ "section": "Personal Rank / Ranking",
+ "loadRule": "切换到 Personal Rank 下的 Ranking 标签时加载;礼物接口参考 KingAndQueen 的 getThisWeekRewardsAndGifts,排行榜数据改用 weekly-star:getWeekStarRanking 拉列表、getMyWeekStarRanking 拉我的排名,getMemberProfile 提供 my-rank 所需 userId;列表底部使用 rankingLoadmore + IntersectionObserver 上拉分页",
+ "giftModule": {
+ "component": "itemCenter",
+ "image": "giftBg",
+ "source": "src/views/Ranking/KingAndQueen/TopList.vue 每周礼物",
+ "rule": "giftBg 已预设 3 个空位,位置与参考页面一致;不再额外使用参考页中的 webpUrl('gift') 单礼物背景,只把 weeklyGifts 的 giftPhoto / giftCandy 放进三个空位",
+ "contentStyle": "padding:0; align-items:center",
+ "innerStyle": "width:80%; display:flex; align-items:flex-start; justify-content:space-around; margin-top:10%"
+ },
+ "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 等级占位",
+ "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"
+ },
+ "roomRewardLayout": {
+ "section": "Room Rank / Reward",
+ "image": "ruleActivity",
+ "layout": "直接展示 ruleActivity 图片,width:100vw; display:block,不额外包奖励配置模块"
+ },
+ "roomRankingLayout": {
+ "section": "Room Rank / Ranking",
+ "source": "复用 Personal Rank / Ranking 的排行榜结构和 weekly-star 上拉分页逻辑",
+ "difference": "不展示活动礼物 giftBg 模块;前三名仍用 GulbenFestival 私有 TopUser 组件和 top1/top2/top3,但头像 border-radius 改为 4px;第四名以后和我的排名使用 RankingItem,只展示排名、头像、名称、ID、金币数量,不展示 vip1 / userLevel1Bg / wealthLevel1Bg 等级图",
+ "data": "Room Rank / Ranking 固定请求 GET /room/contribution/total-rank,参数 startDate=20260518、endDate=20260524、size=20;rankList 渲染排行榜,current 渲染我的房间榜;展示账号优先取 userProfile.ownSpecialId.account,没有时取 userProfile.account;贡献值取 totalFormat;点击房间榜条目时使用 userProfile.inRoomId 调用 src/utils/appBridge.js 的 gotoRoom 方法"
+ },
+ "taskModuleLayout": {
+ "source": "任务接口和展示字段先参考 LuckyDollars Season5 的 taskList 模块",
+ "wrapperStyle": "width:100vw; display:flex; flex-direction:column; gap:2vw; margin:0; padding:0 5vw; box-sizing:border-box",
+ "itemComponent": "itemCenter",
+ "itemImage": "taskItemBg",
+ "contentStyle": "display:flex; padding:8px; justify-content:space-between; align-items:center",
+ "content": "左侧展示 showTarget(task) 和 Rewards:*rewardValue;右侧根据 task.taskStatus 使用 itemCenter 展示 taskBtGo / taskBtReceive / taskBtReceived,并写入 t('task_go') / t('task_receive') / t('task_received');不展示 Season5 的任务类型图标和票券图标",
+ "buttonStyle": "右侧按钮 itemCenter style=min-height:7.79vw;未完成/可领取文字 font-size:1em,已领取文字 font-size:0.9em,font-weight:600,white-space:nowrap",
+ "data": "当前 Gulben 页面所有涉及 activityId 的接口调用都固定传 gulbenActivityId 的值,也就是字符串 '1001';任务列表调用 ActTaskList('1001'),领取调用 receiveTickets({ activityId:'1001', taskCode })"
+ },
+ "placeholderStyle": "min-height:58vw; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:0; margin:0; padding:0",
+ "previewImageClass": "preview-image",
+ "placeholderCopyStyle": "color:rgba(255,246,216,.86); font-size:1em; font-weight:700; text-align:center"
+ }
+ ],
+ "styleRules": {
+ "inline": "固定不变、只用一次的布局/尺寸/间距/文本展示样式优先内联,便于开发预览调试",
+ "class": "复用样式、状态样式、RTL、媒体查询、伪类和复杂模块样式保留 class",
+ "dynamicStyle": "不优先使用 :style 对象;状态切换优先切 class",
+ "fontSize": "除基础 media query 设置字号外,页面内展示字号统一使用 1em,便于跟随响应式基准字号变化",
+ "containerBackground": "模块配置中 images.container / containerImages 这类大背景图统一使用 itemCenter,宽度 100vw,从上往下 flex 排列;开发初期 gap、padding、margin 先写 0,不额外加边框虚线。",
+ "proposalBeforeChange": "后续开发中,只要满足效果和布局,Codex 可以优先考虑性能和用户体验更好的样式/动画方案;但如果方案会改变当前结构、样式写法或交互表现,需要先提出方案并询问是否使用,不要直接替换。",
+ "styleBlock": [
+ "* { color: #fff; }",
+ ".fullPage { width:100%; min-height:100vh; overflow-x:hidden; background:#0B190D; }",
+ "@media screen and (max-width: 360px) { * { font-size: 10px; } }",
+ "@media screen and (min-width: 360px) { * { font-size: 12px; } }",
+ "@media screen and (min-width: 768px) { * { font-size: 24px; } }",
+ "@media screen and (min-width: 1024px) { * { font-size: 32px; } }",
+ "[dir='rtl'] .rule-row { justify-content: flex-start; }"
+ ]
+ }
+}
+```
+
+## 6. 建页后记录
+
+已完成:
+
+- 创建 `src/views/Activities/GulbenFestival/index.vue`
+- 创建 `src/views/Activities/GulbenFestival/page.config.js`
+- 根据本文档重新同步 `page.config.js`
+- 根据本文档重新生成 `index.vue` 可预览底座
+- 补充倒计时配置:使用 `timeBg` 分别展示天/时/分/秒,天数补 `D`,时分秒之间使用英文冒号
+- 调整一级/二级标签页按钮选中态:按钮内部文字 `.image-tab-active span` 字体颜色为 `#5B0000`
+- 调整倒计时和初始化方式参考 `src/views/Activities/LuckyDollars/Season5/index.vue`:倒计时改为 `Days` / `Hours` / `Minutes` / `Seconds` 四个 `ref`,通过 `calculateCountdown()` 和 `startCountdown()` 每秒刷新;`completePreloading()` 中并行执行 `initData()` 和关键图预加载,`initData()` 负责调用 `activityDetail(activityCode)` 获取活动详情以及任务列表接口;倒计时目标时间直接在 `getTargetTime()` 中使用 `new Date(2026, 5, 1, 5, 0, 0)`,不解析中文时间
+- 按实时 Vue 同步当前布局样式:内容区 `padding: 100vw 4vw 12vw`;首行使用 `ruleBtBg` 规则按钮,默认贴右、阿语贴左;固定单点样式优先内联
+- 补充 `