feat(世界杯活动): 对接接口,并完成两个页面的基本内容

This commit is contained in:
hzj 2026-06-08 17:30:42 +08:00
parent 4b73849827
commit 91fc37a0ee
11 changed files with 2892 additions and 479 deletions

65
src/api/worldCup.js Normal file
View File

@ -0,0 +1,65 @@
import { get, post } from '../utils/http.js'
const WORLD_CUP_API_PREFIX = '/activity/worldcup'
const logWorldCupApiError = (message, error) => {
console.error(message, error)
console.error('error:' + (error.response?.errorMsg || error.errorMsg || error.message || ''))
}
// 获取世界杯比赛列表tab 为 0 时返回进行中/可投注tab 为 1 时返回已结束。
export const apiPostWorldCupMatchList = async (data) => {
try {
const response = await post(`${WORLD_CUP_API_PREFIX}/match/list`, data)
return response
} catch (error) {
logWorldCupApiError('Failed to get world cup match list:', error)
throw error
}
}
// 提交世界杯比赛押注betOption 为 1 主胜、2 平局、3 客胜。
export const apiPostWorldCupBet = async (data) => {
try {
const response = await post(`${WORLD_CUP_API_PREFIX}/bet`, data)
return response
} catch (error) {
logWorldCupApiError('Failed to submit world cup bet:', error)
throw error
}
}
// 获取我的世界杯竞猜记录matchId 为空时查询全部比赛记录。
export const apiPostWorldCupMyBetList = async (data) => {
try {
const response = await post(`${WORLD_CUP_API_PREFIX}/my/bet/list`, data)
return response
} catch (error) {
logWorldCupApiError('Failed to get world cup my bet list:', error)
throw error
}
}
// 获取世界杯排行榜接口没有业务参数POST 请求仍传空对象。
export const apiPostWorldCupRankList = async () => {
try {
const response = await post(`${WORLD_CUP_API_PREFIX}/rank/list`, {})
return response
} catch (error) {
logWorldCupApiError('Failed to get world cup rank list:', error)
throw error
}
}
// 获取世界杯比赛详情,同时返回当前用户本场竞猜记录。
export const apiGetWorldCupMatchDetail = async (matchId) => {
try {
const response = await get(
`${WORLD_CUP_API_PREFIX}/match/detail?matchId=${encodeURIComponent(matchId)}`,
)
return response
} catch (error) {
logWorldCupApiError('Failed to get world cup match detail:', error)
throw error
}
}

View File

@ -23,6 +23,7 @@ export const PUBLIC_PATHS = Object.freeze([
'/activities/gulben-festival', '/activities/gulben-festival',
'/activities/bounty-football', '/activities/bounty-football',
'/activities/world-cup', '/activities/world-cup',
'/activities/world-cup/event-detail',
'/activities/lucky-dollars-season5', '/activities/lucky-dollars-season5',
'/activities/lucky-dollars-season4', '/activities/lucky-dollars-season4',
'/activities/lesser-bairam', '/activities/lesser-bairam',

View File

@ -452,6 +452,12 @@ const router = createRouter({
component: () => import('../views/Activities/WorldCup/index.vue'), component: () => import('../views/Activities/WorldCup/index.vue'),
meta: { requiresAuth: false }, meta: { requiresAuth: false },
}, // WorldCup }, // WorldCup
{
path: '/activities/world-cup/event-detail',
name: 'world-cup-event-detail',
component: () => import('../views/Activities/WorldCup/EventDetail/index.vue'),
meta: { requiresAuth: false },
}, // WorldCup EventDetail
{ {
path: '/activities/games/the-greedy-king', path: '/activities/games/the-greedy-king',
name: 'games-the-greedy-king', name: 'games-the-greedy-king',

View File

@ -69,6 +69,7 @@ const ACTIVITIES = [
'/activities/spring-festival', // 春节打榜 '/activities/spring-festival', // 春节打榜
'/activities/world-cup', // WorldCup '/activities/world-cup', // WorldCup
'/activities/world-cup/event-detail', // WorldCup EventDetail
'/activities/games/the-greedy-king', //贪金王游戏排行榜 '/activities/games/the-greedy-king', //贪金王游戏排行榜
'/activities/bounty-football', // BountyFootball '/activities/bounty-football', // BountyFootball
'/activities/poker-ace', // 扑克王牌排行榜 '/activities/poker-ace', // 扑克王牌排行榜

View File

@ -0,0 +1,827 @@
<template>
<!-- 页面根容器赛事详情页使用 100vh 固定视口内部主内容滚动并隐藏滚动条 -->
<div
style="
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #101e14;
color: #fff;
"
>
<!-- 首屏加载动画详情接口和关键图片完成后再展示页面内容 -->
<div
v-if="isLoading"
style="
position: relative;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
"
>
<LoadingContent>{{ $t('loading') }}...</LoadingContent>
</div>
<!-- 正式页面内容加载完成后才渲染背景图头部和业务模块 -->
<div
v-else
style="
position: relative;
z-index: 1;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
"
>
<!-- 最底层背景图detailPageBg 只按 100vw 宽度展示高度按图片自身比例不强制拉伸 -->
<img
v-smart-img.noFade
:src="pngUrl('detailPageBg')"
alt=""
style="position: absolute; inset: 0; z-index: 0; display: block; width: 100vw; height: auto"
/>
<!-- 顶部导航使用公共 GeneralHeader标题为 World Cup非首页返回返回图标使用 arrowBack -->
<div style="position: relative; z-index: 1">
<GeneralHeader title="World Cup" :isHomePage="false" :backImg="arrowBackImg" />
</div>
<!-- 主内容滚动区后续模块按 column 顺序补充隐藏可见滚动条 -->
<main
class="world-cup-event-detail-scroll"
style="
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
gap: 2vw;
overflow-x: hidden;
overflow-y: auto;
padding: 2vw 2vw 8vw;
box-sizing: border-box;
scrollbar-width: none;
-ms-overflow-style: none;
"
>
<!-- 赛事结果模块按未开始进行中已结束中奖已结束未中奖/未参与展示不同状态 -->
<itemCenter
style="position: relative; width: 100%"
:imgUrl="pngUrl(resultStatusConfig.image)"
contentStyle=" padding: 9vw 5vw 2vw 5vw; box-sizing: border-box; display: flex; flex-direction: column; align-items: stretch; justify-content: space-between; gap: 0vw;"
>
<!-- 状态角标绝对定位贴左上角颜色随赛事结果状态变化 -->
<div :style="resultStatusConfig.badgeStyle">
{{ resultStatusConfig.badgeText }}
</div>
<!-- 状态主文案2行只切换展示内容文本字体颜色大小字重保持同一套样式 -->
<div
style="
display: flex;
align-items: center;
justify-content: flex-start;
gap: 1vw;
color: #f8e89a;
font-size: 2em;
font-weight: 500;
"
>
<template v-if="resultStatusConfig.type === 'endedWin'">
<span style="color: #f8e89a; font-size: 1em; font-weight: 500"> + </span>
<img
v-smart-img
src="/src/assets/icon/Azizi/coin.png"
alt=""
style="display: block; width: 1.5em; aspect-ratio: 1/1; object-fit: cover"
/>
<span style="color: #f8e89a; font-size: 1em; font-weight: 500">
{{ winAmount }}
</span>
</template>
<span v-else style="color: #f8e89a; font-size: 1em; font-weight: 500">
{{ resultStatusConfig.mainText }}
</span>
</div>
<!-- 开赛时间说明所有状态固定展示时间内容统一根据 kickoffTime 字段格式化 -->
<div style="color: #f6efaa; font-size: 0.9em; font-weight: 500">
{{ kickoffText }}
</div>
<!-- 分割线区分状态信息和奖池/人数信息 -->
<div style="width: 70%; height: 0.5px; background: rgba(246, 239, 170, 0.5)"></div>
<!-- 奖池和人数行左侧总奖池右侧参与人数 -->
<div
style="
width: 70%;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 4vw;
"
>
<!-- 总奖池信息上方标题下方 coin totalPool -->
<div style="display: flex; flex-direction: column; align-items: flex-start; gap: 1vw">
<div style="color: #f8e89a; font-size: 1em; font-weight: 500">total prize pool:</div>
<div style="display: flex; align-items: center; gap: 1vw">
<img
v-smart-img
src="/src/assets/icon/Azizi/coin.png"
alt=""
style="display: block; width: 2.5em; aspect-ratio: 1/1; object-fit: cover"
/>
<span style="color: #f8e89a; font-size: 1.5em; font-weight: 500">
{{ matchInfo.totalPool || 0 }}
</span>
</div>
</div>
<!-- 参与人数信息numPeopleGold 图片加 joinCount -->
<div
style="
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1vw;
white-space: nowrap;
"
>
<img
v-smart-img.noFade
:src="pngUrl('numPeopleGold')"
alt=""
style="display: block; width: 2em; aspect-ratio: 1/1; object-fit: contain"
/>
<span style="color: #f8e89a; font-size: 1.5em; font-weight: 500">
{{ matchInfo.joinCount || 0 }}
</span>
</div>
</div>
</itemCenter>
<!-- 押注模块未开始赛事可选择押注对象并点击金额下注已开始/已结束赛事只展示已押注状态 -->
<section :style="betModuleStyle">
<!-- 押注对象模块三列展示主胜平局客胜结构对齐首页 Ended 列表项 -->
<div
style="
width: 100%;
display: flex;
align-items: stretch;
justify-content: center;
gap: 2vw;
box-sizing: border-box;
"
>
<!-- 单个押注对象未开始时可点击选中其他状态只展示用户已押注项 -->
<div
v-for="betOption in detailBetOptions"
:key="betOption.option"
:style="getDetailBetOptionStyle(betOption.option)"
@click="handleBetOptionClick(betOption.option)"
>
<!-- 押注对象赛事信息旗帜国家名和占比 -->
<div
style="
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 1vw;
"
>
<div
style="
width: 100%;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
"
>
<img
v-if="betOption.isOssImage"
v-smart-img.noFade
:src="betOption.flagUrl"
alt=""
style="display: block; width: 78%; aspect-ratio: 1 / 1; object-fit: contain"
/>
<img
v-else
:src="betOption.flagUrl"
alt=""
style="display: block; width: 78%; aspect-ratio: 1 / 1; object-fit: contain"
/>
</div>
<div
style="
max-width: 100%;
overflow: hidden;
color: #fff;
font-size: 1em;
font-weight: 500;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
"
>
{{ betOption.name }}
</div>
<div :style="`color: ${betOption.percentColor}; font-size: 1em; font-weight: 500;`">
{{ formatMatchPercent(betOption.percent) }}
</div>
</div>
<!-- 押注对象金额信息上方用户累计下注额下方选项奖池 -->
<div
style="
border-radius: 4px;
border: 0.5px solid #e9d089;
background: linear-gradient(
180deg,
rgba(79, 77, 33, 0.5) 0%,
rgba(79, 77, 33, 0) 100%
);
backdrop-filter: blur(13.449894905090332px);
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.2vw;
margin-top: 2vw;
"
>
<!-- 用户下注金额未押注时展示 0 -->
<div style="display: flex; align-items: center; justify-content: center">
<img
v-smart-img
src="/src/assets/icon/Azizi/coin.png"
alt=""
style="display: block; width: 1.5em; aspect-ratio: 1/1; object-fit: cover"
/>
<span style="color: #fff; font-size: 1em; font-weight: 500">
{{ betOption.myBetAmount }}
</span>
</div>
<div style="width: 100%; height: 1px; background: #e9d089"></div>
<!-- 选项奖池金额使用详情接口格式化字段优先展示 -->
<div style="display: flex; align-items: center; justify-content: center">
<img
v-smart-img
src="/src/assets/icon/Azizi/coin.png"
alt=""
style="display: block; width: 1.5em; aspect-ratio: 1/1; object-fit: cover"
/>
<span style="color: #fff; font-size: 1em; font-weight: 500">
{{ betOption.poolAmount }}
</span>
</div>
</div>
</div>
</div>
<!-- 押注金额模块仅未开始赛事展示选中押注对象后点击金额才提交押注 -->
<div
v-if="canSubmitBet"
style="
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2vw;
"
>
<!-- 单个押注金额按钮点击后调用 apiPostWorldCupBet -->
<div
v-for="amount in betAmountOptions"
:key="amount"
:style="betAmountButtonStyle"
@click="handleBetAmountClick(amount)"
>
<img
v-smart-img
src="/src/assets/icon/Azizi/coin.png"
alt=""
style="display: block; width: 1.4em; aspect-ratio: 1/1; object-fit: cover"
/>
<span style="color: #fff; font-size: 1em; font-weight: 500">{{ amount }}</span>
</div>
</div>
</section>
<!-- 押注记录模块展示当前赛事内用户押注对象金额和押注时间 -->
<section :style="predictionRecordModuleStyle">
<!-- 固定标题模块说明下方列表为本场竞猜记录 -->
<div style="color: #ffe083; font-size: 1em; font-weight: 500">
My prediction for this game:
</div>
<!-- 竞猜记录空态没有本场记录时展示 footballSign 和空文案 -->
<div
v-if="!predictionRecords.length"
style="
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2vw;
"
>
<img
v-smart-img.noFade
:src="pngUrl('footballSign')"
alt=""
style="display: block; width: 30vw; aspect-ratio: 1/1; object-fit: contain"
/>
<div style="color: #ffe083; font-size: 1.5em; font-weight: 500">No records yet.</div>
</div>
<!-- 竞猜记录列表有记录时内部滚动并隐藏可见滚动条 -->
<div
v-else
class="world-cup-event-detail-record-scroll"
style="
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
gap: 2vw;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
"
>
<!-- 单条竞猜记录左侧押注对象和时间右侧押注金额 -->
<div
v-for="recordItem in predictionRecords"
:key="recordItem.betId || `${recordItem.matchId}-${recordItem.betTime}`"
style="
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 3vw;
padding: 2vw 3vw;
box-sizing: border-box;
border-radius: 8px;
border: 0.5px solid #e9d089;
background: linear-gradient(
180deg,
rgba(79, 77, 33, 0.5) 0%,
rgba(79, 77, 33, 0) 100%
);
backdrop-filter: blur(13.449894905090332px);
"
>
<!-- 记录左侧押注选项名和押注时间 -->
<div
style="
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1vw;
overflow: hidden;
"
>
<div
style="
max-width: 100%;
overflow: hidden;
color: #ffe083;
font-size: 1em;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
"
>
Bets placed: {{ recordItem.betOptionName || '' }}
</div>
<div style="color: #ffe083; font-size: 1em; font-weight: 500">
{{ formatPredictionTime(recordItem.betTime) }}
</div>
</div>
<!-- 记录右侧coin 图片和押注金额 -->
<div
style="
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1vw;
flex: 0 0 auto;
white-space: nowrap;
"
>
<img
v-smart-img
src="/src/assets/icon/Azizi/coin.png"
alt=""
style="display: block; width: 1.5em; aspect-ratio: 1/1; object-fit: cover"
/>
<span style="color: #fff; font-size: 1em; font-weight: 500">
{{ recordItem.betAmount || 0 }}
</span>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getPngUrl } from '@/config/imagePaths.js'
import { preloadImages } from '@/utils/image/imagePreloader.js'
import {
apiGetWorldCupMatchDetail,
apiPostWorldCupBet,
apiPostWorldCupMyBetList,
} from '@/api/worldCup.js'
import GeneralHeader from '@/components/GeneralHeader.vue'
import itemCenter from '@/components/itemCenter.vue'
import arrowBackImg from '@/assets/icon/Azizi/arrowBack.png'
import { showError, showSuccess, showWarning } from '@/utils/toast.js'
const route = useRoute()
const assetPath = 'Activities/WorldCup/'
const pngUrl = (filename) => getPngUrl(assetPath, filename)
const matchId = computed(() => route.query.matchId || '')
const isLoading = ref(true)
const matchInfo = ref({})
const myBets = ref([])
const nowTime = ref(Date.now())
const selectedBetOption = ref(null)
const isSubmittingBet = ref(false)
let countdownTimer = null
const blockingImages = [
'detailPageBg',
'statusNotStarted',
'statusInProgress',
'statusEndedWin',
'statusEndedfail',
'numPeopleGold',
'drawSign',
'footballSign',
]
const notStartedBadgeStyle =
'position: absolute; left: 0; top: 0; z-index: 2; padding: 1.5vw 3vw; border-radius: 7px 0; background: linear-gradient(152deg, #FB9631 7.01%, #B86F21 92.99%), linear-gradient(129deg, #823C1B 0%, #853D1D 14.44%, #AB6426 28.88%, #FF9A46 43.32%, #F28F28 57.76%, #AD6324 72.2%, #813D1C 96.26%); color: #FFF; font-size: 1em; font-weight: 500;'
const inProgressBadgeStyle =
'position: absolute; left: 0; top: 0; z-index: 2; padding: 1.5vw 3vw; border-radius: 7px 0; background: linear-gradient(152deg, #31FB5E 7.01%, #21B843 92.99%), linear-gradient(129deg, #1B8230 0%, #1D8532 14.44%, #26AB26 28.88%, #46FF5F 43.32%, #28F22B 57.76%, #2DAD24 72.2%, #1C8132 96.26%); color: #FFF; font-size: 1em; font-weight: 500;'
const endedBadgeStyle =
'position: absolute; left: 0; top: 0; z-index: 2; padding: 1.5vw 3vw; border-radius: 7px 0; background: linear-gradient(135deg, #484646 2.82%, #292424 99.15%), linear-gradient(129deg, #823C1B 0%, #853D1D 14.44%, #AB6426 28.88%, #FF9A46 43.32%, #F28F28 57.76%, #AD6324 72.2%, #813D1C 96.26%); color: #FFF; font-size: 1em; font-weight: 500;'
const betModuleStyle =
'width: 100%; display: flex; flex-direction: column; gap: 3vw; padding: 3vw; box-sizing: border-box; border-radius: 12px; border: 1px solid #5D6948; background: linear-gradient(180deg, #0B2415 0%, #051C12 100%); backdrop-filter: blur(14.252873420715332px);'
const betOptionDefaultStyle =
'flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; align-items: center; padding: 2vw 1.5vw; box-sizing: border-box; border-radius: 7.121px; border: 0.445px solid #E9D089; background: linear-gradient(180deg, rgba(79, 77, 33, 0.50) 0%, rgba(79, 77, 33, 0.00) 100%); backdrop-filter: blur(11.97230052947998px);'
const betOptionSelectedStyle =
'flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; align-items: center; padding: 2vw 1.5vw; box-sizing: border-box; border-radius: 7.121px; border: 1px solid #FFFA5D; background: linear-gradient(180deg, rgba(79, 77, 33, 0.50) 0%, rgba(79, 77, 33, 0.00) 100%); backdrop-filter: blur(11.97230052947998px); box-shadow: -0.223px -0.223px 0.89px 0 #D6C100, 0.223px 0.223px 0.89px 0 #D6C100, 0 0 2.67px 0 rgba(255, 250, 93, 0.60), 0 0 4.451px 0 rgba(255, 250, 93, 0.40), 0 0 3.561px 0 rgba(210, 204, 0, 0.30);'
const betAmountButtonStyle =
'flex: 1; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 0.8vw; padding: 2vw 1vw; box-sizing: border-box; border-radius: 4px; border: 0.5px solid #E9D089; background: linear-gradient(180deg, rgba(79, 77, 33, 0.50) 0%, rgba(79, 77, 33, 0.00) 100%); backdrop-filter: blur(13.449894905090332px);'
const betAmountOptions = [100, 1000, 10000, 100000]
const predictionRecordModuleStyle =
'width: 100%; height: 72vw; display: flex; flex-direction: column; gap: 3vw; padding: 3vw; box-sizing: border-box; border-radius: 12px; border: 1px solid #5D6948; background: linear-gradient(180deg, #0B2415 0%, #051C12 100%); backdrop-filter: blur(14.252873420715332px);'
//
const canSubmitBet = computed(() => Number(matchInfo.value.status) === 0)
// myBets betStatus=1 / payout>0
const hasWinBet = computed(() => {
return myBets.value.some(
(betItem) => Number(betItem.betStatus) === 1 || Number(betItem.payout) > 0,
)
})
// payout
const winAmount = computed(() => {
return myBets.value.reduce((total, betItem) => total + (Number(betItem.payout) || 0), 0)
})
//
const detailBetOptions = computed(() => [
{
option: 1,
flagUrl: matchInfo.value.homeFlag,
isOssImage: false,
name: matchInfo.value.homeTeam || '',
percent: matchInfo.value.homePercent,
percentColor: '#71ce6e',
myBetAmount: getMyBetAmountText(1),
poolAmount: matchInfo.value.homePoolFormat || matchInfo.value.homePool || 0,
},
{
option: 2,
flagUrl: pngUrl('drawSign'),
isOssImage: true,
name: 'DRAW',
percent: matchInfo.value.drawPercent,
percentColor: '#a0a181',
myBetAmount: getMyBetAmountText(2),
poolAmount: matchInfo.value.drawPoolFormat || matchInfo.value.drawPool || 0,
},
{
option: 3,
flagUrl: matchInfo.value.awayFlag,
isOssImage: false,
name: matchInfo.value.awayTeam || '',
percent: matchInfo.value.awayPercent,
percentColor: '#71ce6e',
myBetAmount: getMyBetAmountText(3),
poolAmount: matchInfo.value.awayPoolFormat || matchInfo.value.awayPool || 0,
},
])
// 使 records
const predictionRecords = computed(() => myBets.value || [])
// 2D 18 : 59 : 59
const countdownText = computed(() => {
const remaining = Math.max(0, Number(matchInfo.value.kickoffTime || 0) - nowTime.value)
const totalSeconds = Math.floor(remaining / 1000)
const days = Math.floor(totalSeconds / 86400)
const hours = String(Math.floor((totalSeconds % 86400) / 3600)).padStart(2, '0')
const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0')
const seconds = String(totalSeconds % 60).padStart(2, '0')
return `${days}D ${hours} : ${minutes} : ${seconds}`
})
// status
const resultStatusConfig = computed(() => {
const status = Number(matchInfo.value.status)
if (status === 0) {
return {
type: 'notStarted',
image: 'statusNotStarted',
badgeText: 'Not Started',
badgeStyle: notStartedBadgeStyle,
mainText: countdownText.value,
}
}
if (status === 1) {
return {
type: 'inProgress',
image: 'statusInProgress',
badgeText: 'In Progress',
badgeStyle: inProgressBadgeStyle,
mainText: 'The match is underway',
}
}
if (hasWinBet.value) {
return {
type: 'endedWin',
image: 'statusEndedWin',
badgeText: 'Ended',
badgeStyle: endedBadgeStyle,
mainText: '',
}
}
return {
type: 'endedFail',
image: 'statusEndedfail',
badgeText: 'Ended',
badgeStyle: endedBadgeStyle,
mainText: 'Better luck next time!',
}
})
// kickoffTime
const kickoffText = computed(() => {
const kickoffTime = Number(matchInfo.value.kickoffTime || 0)
if (!kickoffTime) return ''
const kickoffDate = new Date(kickoffTime)
const timeText = `${String(kickoffDate.getHours()).padStart(2, '0')}:${String(
kickoffDate.getMinutes(),
).padStart(2, '0')}:${String(kickoffDate.getSeconds()).padStart(2, '0')}`
const monthText = kickoffDate.toLocaleString('en-US', { month: 'long' })
const dateText = `${kickoffDate.getDate()} ${monthText} ${kickoffDate.getFullYear()}`
return `Kick-off is at ${timeText} on ${dateText}.`
})
//
const formatMatchPercent = (percent) => {
if (typeof percent === 'string' && percent.includes('%')) return percent
const percentValue = percent === '' || percent === null || percent === undefined ? 0 : percent
return `${percentValue}%`
}
// MM-DD-YYYY HH:mm:ss
const formatPredictionTime = (betTime) => {
const timestamp = Number(betTime)
if (!timestamp) return ''
const betDate = new Date(timestamp)
const month = String(betDate.getMonth() + 1).padStart(2, '0')
const day = String(betDate.getDate()).padStart(2, '0')
const year = betDate.getFullYear()
const hours = String(betDate.getHours()).padStart(2, '0')
const minutes = String(betDate.getMinutes()).padStart(2, '0')
const seconds = String(betDate.getSeconds()).padStart(2, '0')
return `${month}-${day}-${year} ${hours}:${minutes}:${seconds}`
}
//
const getMyBetAmountSum = (option) => {
return myBets.value.reduce((total, betItem) => {
if (Number(betItem.betOption) !== Number(option)) return total
return total + (Number(betItem.betAmount) || 0)
}, 0)
}
// 使 my*BetFormat myBets
const getMyBetAmountText = (option) => {
const formatMap = {
1: matchInfo.value.myHomeBetFormat,
2: matchInfo.value.myDrawBetFormat,
3: matchInfo.value.myAwayBetFormat,
}
const formatValue = formatMap[Number(option)]
if (formatValue !== null && formatValue !== undefined && formatValue !== '') return formatValue
const amountSum = getMyBetAmountSum(option)
return amountSum || 0
}
//
const hasUserBetOnOption = (option) => {
const formatMap = {
1: matchInfo.value.myHomeBetFormat,
2: matchInfo.value.myDrawBetFormat,
3: matchInfo.value.myAwayBetFormat,
}
const formatValue = formatMap[Number(option)]
if (formatValue !== null && formatValue !== undefined && formatValue !== '') return true
return getMyBetAmountSum(option) > 0
}
//
const getDetailBetOptionStyle = (option) => {
if (canSubmitBet.value) {
return Number(selectedBetOption.value) === Number(option)
? betOptionSelectedStyle
: betOptionDefaultStyle
}
return hasUserBetOnOption(option) ? betOptionSelectedStyle : betOptionDefaultStyle
}
//
const handleBetOptionClick = (option) => {
if (!canSubmitBet.value) return
selectedBetOption.value = option
}
//
const loadMatchDetail = async () => {
if (!matchId.value) return
const detailRes = await apiGetWorldCupMatchDetail(matchId.value)
const detailBody = detailRes.body || detailRes
matchInfo.value = {
...(detailBody.match || {}),
homePool: detailBody.homePool ?? 0,
drawPool: detailBody.drawPool ?? 0,
awayPool: detailBody.awayPool ?? 0,
homePoolFormat: detailBody.match?.homePoolFormat ?? detailBody.homePoolFormat ?? '',
drawPoolFormat: detailBody.match?.drawPoolFormat ?? detailBody.drawPoolFormat ?? '',
awayPoolFormat: detailBody.match?.awayPoolFormat ?? detailBody.awayPoolFormat ?? '',
myHomeBetFormat: detailBody.match?.myHomeBetFormat ?? detailBody.myHomeBetFormat ?? null,
myDrawBetFormat: detailBody.match?.myDrawBetFormat ?? detailBody.myDrawBetFormat ?? null,
myAwayBetFormat: detailBody.match?.myAwayBetFormat ?? detailBody.myAwayBetFormat ?? null,
}
myBets.value = detailBody.myBets || []
}
// myBets
const loadMyBetList = async () => {
if (!matchId.value) return
const betRes = await apiPostWorldCupMyBetList({
size: 100,
current: 1,
matchId: matchId.value,
})
const betBody = betRes.body || betRes
const records = betBody.records || []
myBets.value = records
}
// loading
const completePreloading = async () => {
try {
await Promise.all([
preloadImages(blockingImages.map((imageName) => pngUrl(imageName))),
loadMatchDetail(),
])
await loadMyBetList()
} catch (error) {
console.error('WorldCup 赛事详情加载失败:', error)
} finally {
isLoading.value = false
}
}
//
const submitWorldCupBet = async (betOption, betAmount) => {
if (!matchId.value) return
return apiPostWorldCupBet({
matchId: Number(matchId.value),
betOption,
betAmount,
})
}
//
const handleBetAmountClick = async (betAmount) => {
if (!canSubmitBet.value) return
if (!selectedBetOption.value) {
showWarning('Please select a prediction option first.')
return
}
if (isSubmittingBet.value) return
try {
isSubmittingBet.value = true
await submitWorldCupBet(selectedBetOption.value, betAmount)
showSuccess('Bet placed successfully.')
selectedBetOption.value = null
await loadMatchDetail()
await loadMyBetList()
} catch (error) {
showError(error.response?.errorMsg || error.errorMsg || error.message || 'Bet failed')
} finally {
isSubmittingBet.value = false
}
}
onMounted(() => {
countdownTimer = window.setInterval(() => {
nowTime.value = Date.now()
}, 1000)
completePreloading()
})
onBeforeUnmount(() => {
if (countdownTimer) {
window.clearInterval(countdownTimer)
countdownTimer = null
}
})
</script>
<style lang="scss" scoped>
* {
color: #fff;
}
.world-cup-event-detail-scroll::-webkit-scrollbar {
display: none;
}
@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;
}
}
</style>

View File

@ -0,0 +1,97 @@
# 页面创建信息
## 0. 建页规则关联
- 页面改动、模块补充、接口对接、样式同步和文档同步,都需要先联系 `docs/活动页/建页/` 下的建页规则执行。
- 本页面为 `WorldCup` 的赛事详情页,后续生成或更新时需要同步回 `src/views/Activities/WorldCup/页面创建信息.md` 的入口说明。
- 页面模块、div 结构、方法、接口处理都需要补充中文注释。
- 页面根容器使用 `100vh` 方案,内部滚动并隐藏滚动条,不展示可见滚动条。
- 能使用内联样式处理的页面样式,优先使用内联样式。
- 新建或更新 `index.vue` 时必须使用统一加载动画:`isLoading` 阶段只渲染 `LoadingContent`,关键图片和初始化接口完成后再渲染正式背景图与业务节点。
- 普通业务模块不提前设置 `min-height``line-height``min-height` 只在基础页面容器按需使用,`itemCenter``min-height` 只在页面开发完成后确实需要稳定图片容器高度时再补。
## 1. 基础信息
- 页面类型Activities
- 页面名称WorldCup EventDetail
- 页面目录:`src/views/Activities/WorldCup/EventDetail`
- 页面入口:`src/views/Activities/WorldCup/EventDetail/index.vue`
- 路由 path`/activities/world-cup/event-detail`
- 路由 name`world-cup-event-detail`
- 进入来源WorldCup 首页赛事列表项下方的 `TOTAL PRIZE POOL` 模块。
- Query 参数:`matchId`,来自比赛列表接口返回的 `matchId`
## 2. 业务规则
- 本页面用于展示世界杯赛事详情,并给用户提供押注服务。
- 押注只允许在赛事未开始时进行。
- 只要赛事开始,用户就不能再押注,只展示赛事状态和用户押注状态。
- 页面详情接口使用 `apiGetWorldCupMatchDetail(matchId)`
- 押注接口使用 `apiPostWorldCupBet(data)`
- 我的本场竞猜记录优先使用详情接口返回的 `myBets`
- 页面进入参数为 `matchId`,由首页赛事列表总奖池入口跳转时通过 query 传入。
## 3. 页面结构
- 根容器固定 `100vw * 100vh`,背景色为 `#101E14`
- 最底层背景图使用 `detailPageBg`,宽度设置为 `100vw`,高度按图片自身比例展示,不强制拉伸到页面高度。
- 顶部使用公共组件 `src/components/GeneralHeader.vue`,标题为 `World Cup`,非首页,使用组件默认返回键。
- `GeneralHeader` 返回键图片通过 `backImg` 指定为 `src/assets/icon/Azizi/arrowBack.png`,只影响当前详情页。
- 主内容区为内部滚动区域,使用 column 布局,`gap: 2vw`,隐藏可见滚动条。
- 第一模块为赛事结果模块,使用 `itemCenter` 组件承载不同状态背景图。
- 第二模块为押注模块,负责展示押注对象、押注状态和未开赛时的押注金额入口。
- 第三模块为押注记录模块,负责展示当前赛事内用户押注对象、金额和押注时间。
## 4. 赛事结果模块
- 未开始状态使用 `statusNotStarted`,角标文案为 `Not Started`,主文案根据 `kickoffTime` 展示倒计时,例如 `2D 18 : 59 : 59`
- 正在进行状态使用 `statusInProgress`,角标文案为 `In Progress`,主文案展示 `The match is underway`
- 已结束且至少有一个中奖押注时使用 `statusEndedWin`,角标文案为 `Ended`,主文案展示 `+ coin + payout合计`
- 已结束且未中奖或未参与时使用 `statusEndedfail`,角标文案为 `Ended`,主文案展示 `Better luck next time!`
- 第2行主文案只按状态切换展示内容字体样式固定一致`color: #F8E89A``font-size: 2em``font-weight: 500`
- 开赛时间文案在所有状态下固定展示,文案根据 `kickoffTime` 格式化,例如 `Kick-off is at 18:00:00 on 12 June 2026.`
- 分割线和底部信息行在所有状态下固定展示;底部信息行左侧展示 `total prize pool:`、coin 图片和 `totalPool`,右侧展示 `numPeopleGold` 图片和 `joinCount`
- 中奖判断来自 `myBets``betStatus === 1``payout > 0` 都视为中奖。
- 当前页面会预加载 `detailPageBg``statusNotStarted``statusInProgress``statusEndedWin``statusEndedfail``numPeopleGold`
## 5. 押注模块
- 押注模块使用普通 `section`,整体样式为绿色渐变背景、`12px` 圆角、`#5D6948` 边框和 `backdrop-filter`
- 模块内部从上往下分为押注对象和押注金额两个小模块。
- 押注对象结构对齐首页 `Ended` 列表项中的三列押注选项:主胜、平局、客胜。
- 押注对象上方金额展示用户在当前选项的累计下注额:`myHomeBetFormat` / `myDrawBetFormat` / `myAwayBetFormat`,详情接口缺失时按 `myBets``betOption` 汇总兜底。
- 押注对象下方金额展示当前选项奖池:`homePoolFormat` / `drawPoolFormat` / `awayPoolFormat`,没有格式化字段时回退 `homePool` / `drawPool` / `awayPool`
- 未开始赛事中,押注对象允许点击选择,默认不选中任何项;选中的押注项使用首页 `Ended` 选中态样式。
- 已开始或已结束赛事中,押注对象不可点击,选中态用于展示用户已押注过的选项。
- 押注金额模块只在未开始赛事展示,金额选项为 `100``1000``10000``100000`,每个金额左侧展示 coin 图片。
- 押注流程为:先选择押注对象,再点击押注金额;点击金额时调用 `apiPostWorldCupBet({ matchId, betOption, betAmount })`
- 押注成功后清空当前选中押注对象,并重新拉取 `apiGetWorldCupMatchDetail``apiPostWorldCupMyBetList` 刷新页面状态。
## 6. 押注记录模块
- 押注记录模块使用普通 `section`,整体样式为绿色渐变背景、`12px` 圆角、`#5D6948` 边框和 `backdrop-filter`
- 数据接口使用 `apiPostWorldCupMyBetList({ size: 100, current: 1, matchId })`,只获取一次,不做上拉加载。
- 模块内部从上往下分为固定标题和竞猜记录两个小模块。
- 固定标题为 `My prediction for this game:`,样式为 `color: #FFE083``font-weight: 500`
- 竞猜记录为空时居中展示两行:第一行为 `footballSign` 图片,宽度暂定 `30vw`;第二行为 `No records yet.`,样式为 `color: #FFE083``font-size: 1.5em``font-weight: 500`
- 竞猜记录不为空时,记录区域内部滚动并隐藏可见滚动条。
- 单条记录样式为金色描边渐变背景,内部左右 `space-between` 布局。
- 记录左侧展示两行:`Bets placed:` + `betOptionName`,以及格式化后的 `betTime`,时间格式为 `MM-DD-YYYY HH:mm:ss`
- 记录右侧展示 coin 图片和 `betAmount`,金额文字为白色、`font-weight: 500`
## 7. 路由代码片段
```js
{
path: '/activities/world-cup/event-detail',
name: 'world-cup-event-detail',
component: () => import('../views/Activities/WorldCup/EventDetail/index.vue'),
meta: { requiresAuth: false },
}
```
## 8. 验证记录
- `npm run build`:已通过。
- 当前已完成赛事结果模块、押注模块和押注记录模块。
- 本次已按 `docs/活动页/建页/` 规则调整详情页底座loading/v-else 分离、中文注释、内部滚动隐藏滚动条、`<style lang="scss" scoped>`

View File

@ -43,7 +43,7 @@
<!-- 用户基础信息名称和展示 id空榜时保留占位高度 --> <!-- 用户基础信息名称和展示 id空榜时保留占位高度 -->
<div class="user-name">{{ name }}</div> <div class="user-name">{{ name }}</div>
<div class="user-id">{{ userId }}</div> <div class="user-id">{{ $t('user_id_prefix') }} {{ userId }}</div>
<!-- 等级展示行VIP魅力等级财富等级规则同步 GulbenFestival --> <!-- 等级展示行VIP魅力等级财富等级规则同步 GulbenFestival -->
<div <div
@ -56,14 +56,20 @@
direction: ltr; direction: ltr;
" "
> >
<img v-if="vipUrl" v-smart-img.noFade :src="vipUrl" alt="" style="width: 24%; display: block" /> <img
v-if="vipUrl"
v-smart-img.noFade
:src="vipUrl"
alt=""
style="width: 24%; display: block"
/>
<itemCenter <itemCenter
:imgUrl="userLevelUrl" :imgUrl="charmLevelUrl"
class="level-badge" class="level-badge"
style="width: 30%" style="width: 30%"
contentStyle="padding: 0 0 0 42%; align-items: center; justify-content: center; box-sizing: border-box;" contentStyle="padding: 0 0 0 42%; align-items: center; justify-content: center; box-sizing: border-box;"
> >
<span>{{ userLevel || 0 }}</span> <span>{{ charmLevel || 0 }}</span>
</itemCenter> </itemCenter>
<itemCenter <itemCenter
:imgUrl="wealthLevelUrl" :imgUrl="wealthLevelUrl"
@ -106,7 +112,7 @@ defineProps({
type: String, type: String,
default: '', default: '',
}, },
userLevelUrl: { charmLevelUrl: {
type: String, type: String,
required: true, required: true,
}, },
@ -114,7 +120,7 @@ defineProps({
type: String, type: String,
required: true, required: true,
}, },
userLevel: { charmLevel: {
type: [String, Number], type: [String, Number],
default: 0, default: 0,
}, },

File diff suppressed because it is too large Load Diff

View File

@ -13,8 +13,50 @@ export const pageConfig = {
}, },
preload: { preload: {
blocking: [], blocking: [
background: [], 'bg2',
'bg',
'ruleBt',
'ruleBg',
'EventlistBg',
'matchBg',
'drawSign',
'numPeople',
'rightBt',
'RankingMain',
'RankingItem',
'RankingBottomBorder',
'myRankingBg',
'giftBt',
'rewardBg',
'showMoreBt',
'frameTop1',
'frameTop2',
'frameTop3',
'vip1',
'vip2',
'vip3',
'vip4',
'vip5',
'vip6',
'userLevel1Bg',
'userLevel2Bg',
'userLevel3Bg',
'userLevel4Bg',
'userLevel5Bg',
'wealthLevel1Bg',
'wealthLevel2Bg',
'wealthLevel3Bg',
'wealthLevel4Bg',
'wealthLevel5Bg',
],
background: [
'footballSign',
'statusEndedfail',
'statusEndedWin',
'statusInProgress',
'statusNotStarted',
],
}, },
modules: { modules: {
@ -24,7 +66,24 @@ export const pageConfig = {
items: [], items: [],
}, },
sections: [], sections: [],
popups: [], popups: [
{
name: 'rule',
enabled: true,
triggerImage: 'ruleBt',
backgroundImage: 'ruleBg',
component: 'MaskLayer',
remark: '头部规则按钮弹窗ruleBg 宽度 100vw占满弹窗宽度。',
},
{
name: 'reward',
enabled: true,
triggerImage: 'giftBt',
backgroundImage: 'rewardBg',
component: 'MaskLayer',
remark: '排行榜右上角礼物按钮弹窗,当前只完成视觉容器。',
},
],
features: [ features: [
{ {
name: 'countdown', name: 'countdown',
@ -35,8 +94,7 @@ export const pageConfig = {
endTime: '', endTime: '',
remainingText: '', remainingText: '',
}, },
remark: remark: '倒计时模块占位;本地图片清单尚未扫描到 timeBg待补充素材和截止时间后再启用。',
'倒计时模块占位;本地图片清单尚未扫描到 timeBg待补充素材和截止时间后再启用。',
}, },
], ],
}, },

View File

@ -0,0 +1,358 @@
# 世界杯接口说明
本文件记录 `src/api/worldCup.js` 中世界杯活动专用接口的参数、返回字段和页面对接注意事项。页面后续补比赛、竞猜、我的记录、排行榜模块时,先对照本文件和 `页面创建信息.md`
## 1. 接口文件
- 文件:`src/api/worldCup.js`
- 公共前缀:`/activity/worldcup`
- 请求封装:`src/utils/http.js` 中的 `get``post`
## 2. POST 接口
### 2.1 比赛列表
- 方法名:`apiPostWorldCupMatchList(data)`
- 请求方式:`POST`
- 路径:`/activity/worldcup/match/list`
- 用途:分页获取比赛列表。
Body 参数:
```json
{
"size": 0,
"current": 0,
"tab": 0
}
```
页面当前调用固定为:
```json
{
"size": 10,
"current": 1,
"tab": 0
}
```
切换到 `Ended` 时仅把 `tab` 改为 `1`,暂不做上拉加载。
参数说明:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| size | 是 | 每页数量 |
| current | 是 | 当前页 |
| tab | 是 | `0` 进行中,可投注或比赛中;`1` 已结束 |
返回示例:
```json
{
"records": [
{
"matchId": 0,
"homeTeam": "string",
"awayTeam": "string",
"homeFlag": "string",
"awayFlag": "string",
"kickoffTime": 0,
"status": 0,
"result": 0,
"totalPool": 0,
"totalPoolFormat": "string",
"homePoolFormat": "string",
"drawPoolFormat": "string",
"awayPoolFormat": "string",
"homePercent": 0,
"drawPercent": 0,
"awayPercent": 0,
"myHomeBetFormat": "string",
"myDrawBetFormat": "string",
"myAwayBetFormat": "string",
"joinCount": 0,
"joined": true,
"myPayout": 0
}
],
"total": 0,
"size": 0,
"current": 0
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| matchId | 比赛 ID |
| homeTeam / awayTeam | 主队 / 客队国家名称 |
| homeFlag / awayFlag | 主队 / 客队旗帜图片链接 |
| kickoffTime | 开赛时间戳,毫秒 |
| status | `0` 未开赛,`1` 比赛中,`2` 已结束 |
| result | `1` 主胜,`2` 平局,`3` 客胜,未结算为 `null` |
| totalPool | 总奖池 |
| totalPoolFormat | 总奖池格式化显示,例如 `123.12M` |
| homePoolFormat / drawPoolFormat / awayPoolFormat | 主胜 / 平局 / 客胜各选项奖池格式化显示,例如 `123.12M` |
| homePercent / drawPercent / awayPercent | 主胜 / 平局 / 客胜占比,百分比,保留 1 位 |
| myHomeBetFormat / myDrawBetFormat / myAwayBetFormat | 当前用户在主胜 / 平局 / 客胜各选项的累计下注额格式化显示,未下注为 `null` |
| joinCount | 参与人次 |
| joined | 当前用户是否已在本场投注 |
| myPayout | 当前用户本场赢得金额中奖且已结算时有值其余为null. |
页面对接注意:
- `Predict Now``Ended` 底部总奖池入口优先展示 `totalPoolFormat`,没有格式化字段时回退 `totalPool`
- `Ended` 列表三列押注项的上方金额展示 `myHomeBetFormat` / `myDrawBetFormat` / `myAwayBetFormat`;字段不为 `null` 的选项视为当前用户已押注并展示选中态。
- `Ended` 列表三列押注项的下方金额展示 `homePoolFormat` / `drawPoolFormat` / `awayPoolFormat`
### 2.2 押注
- 方法名:`apiPostWorldCupBet(data)`
- 请求方式:`POST`
- 路径:`/activity/worldcup/bet`
- 用途:对指定比赛提交竞猜押注。
Body 参数:
```json
{
"matchId": 0,
"betOption": 0,
"betAmount": 0
}
```
参数说明:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| matchId | 是 | 比赛 ID |
| betOption | 是 | 押注选项:`1` 主胜,`2` 平局,`3` 客胜 |
| betAmount | 是 | 押注金额:`100` / `1000` / `10000` / `100000` |
### 2.3 我的竞猜记录
- 方法名:`apiPostWorldCupMyBetList(data)`
- 请求方式:`POST`
- 路径:`/activity/worldcup/my/bet/list`
- 用途:分页获取我的竞猜记录。
Body 参数:
```json
{
"size": 0,
"current": 0,
"matchId": 0
}
```
参数说明:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| size | 是 | 每页数量 |
| current | 是 | 当前页 |
| matchId | 否 | 比赛 ID为空查全部 |
返回示例:
```json
{
"records": [
{
"betId": 0,
"matchId": 0,
"betOption": 0,
"betOptionName": "string",
"betAmount": 0,
"betStatus": 0,
"payout": 0,
"betTime": 0
}
],
"total": 0,
"size": 0,
"current": 0
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| betId | 竞猜记录 ID |
| matchId | 比赛 ID |
| betOption | 押注选项:`1` 主胜,`2` 平局,`3` 客胜 |
| betOptionName | 押注选项展示文案,队名或平局 |
| betAmount | 押注金额 |
| betStatus | `0` 待结算,`1` 中奖,`2` 未中奖 |
| payout | 派彩金额,中奖时展示 |
| betTime | 下注时间戳,毫秒 |
### 2.4 排行榜
- 方法名:`apiPostWorldCupRankList()`
- 请求方式:`POST`
- 路径:`/activity/worldcup/rank/list`
- Body 参数无业务参数POST 请求仍传空对象 `{}`
- 用途:获取世界杯活动排行榜和当前用户排名。
- 页面字段映射:
- `rankingList` 对应页面排行榜列表,第四名以后才进入展开列表。
- `currentUserRank` 对应贴底“我的排名”模块。
- `quantity` 对应页面贡献值 `amount`
- 带 `user_id_prefix` 的展示 ID 取 `account`;打开用户资料使用 `userId`
- 昵称取 `nickname`,头像取 `avatar`,魅力等级取 `charmLevel`,财富等级取 `wealthLevel`VIP 取 `vipLevelName``userLevel` 当前页面暂不使用。
返回示例:
```json
{
"activityType": 0,
"activityTypeName": "string",
"cycleType": 0,
"cycleTypeName": "string",
"cycleKey": "string",
"cycleDisplay": "string",
"dimension": 0,
"dimensionName": "string",
"dimensionUnit": "string",
"rankingList": [
{
"userId": 0,
"encryptedId": "string",
"account": "string",
"specialAccount": "string",
"nickname": "string",
"avatar": "string",
"userSex": 0,
"userLevel": 0,
"rank": 0,
"quantity": "string",
"quantityNumber": 0,
"activityType": 0,
"activityTypeName": "string",
"cycleType": 0,
"cycleTypeName": "string",
"cycleKey": "string",
"dimension": 0,
"dimensionName": "string",
"dimensionUnit": "string",
"extData": "string",
"age": 0,
"wealthLevel": 0,
"vipLevelName": "string",
"isCurrentUser": true
}
],
"currentUserRank": {
"userId": 0,
"account": "string",
"userSex": 0,
"nickname": "string",
"avatar": "string",
"rank": 0,
"quantity": "string",
"quantityNumber": 0,
"isRanked": true,
"quantityToRank": 0,
"rankChange": 0,
"lastRank": 0,
"age": 0,
"wealthLevel": 0,
"userLevel": 0,
"vipLevelName": "string"
},
"totalParticipants": 0,
"updateTime": "string"
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| cycleDisplay | 周期显示文本,例如日榜 `2025-01-01`、周榜 `2025年第1周`、月榜 `2025年1月` |
| dimension / dimensionName / dimensionUnit | 排行统计维度编码、名称、单位 |
| rankingList | 排行榜 TOP N 列表 |
| currentUserRank | 当前用户排名,`rank=0` 表示未上榜 |
| quantity / quantityNumber | 统计数值展示文本 / 数值 |
| quantityToRank | 距离上榜还需要的数值,仅未上榜时有值 |
| rankChange | 排名变化正数上升负数下降0 不变 |
| totalParticipants | 参与总人数 |
| updateTime | 榜单更新时间 |
页面对接注意:
- 排行榜用户头像、昵称、VIP、魅力等级、财富等级按 `apiPostWorldCupRankList` 当前返回字段映射。
- 页面贡献值别名 `amount` 使用 `quantity``quantityNumber` 原样保留备用。
- `encryptedId` 可用于隐身用户和 `currentUserRank` 匹配。
## 3. GET 接口
### 3.1 比赛详情
- 方法名:`apiGetWorldCupMatchDetail(matchId)`
- 请求方式:`GET`
- 路径:`/activity/worldcup/match/detail`
- Query 参数:`matchId` 必填
- 用途:获取比赛详情和当前用户本场竞猜记录。
返回示例:
```json
{
"match": {
"matchId": 0,
"homeTeam": "string",
"awayTeam": "string",
"homeFlag": "string",
"awayFlag": "string",
"kickoffTime": 0,
"status": 0,
"result": 0,
"totalPool": 0,
"totalPoolFormat": "string",
"homePoolFormat": "string",
"drawPoolFormat": "string",
"awayPoolFormat": "string",
"homePercent": 0,
"drawPercent": 0,
"awayPercent": 0,
"myHomeBetFormat": "string",
"myDrawBetFormat": "string",
"myAwayBetFormat": "string",
"joinCount": 0,
"joined": true
},
"homePool": 0,
"drawPool": 0,
"awayPool": 0,
"myBets": [
{
"betId": 0,
"matchId": 0,
"betOption": 0,
"betOptionName": "string",
"betAmount": 0,
"betStatus": 0,
"payout": 0,
"betTime": 0
}
]
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| match | 比赛基础信息,字段同比赛列表 |
| match.totalPoolFormat | 总奖池格式化显示,例如 `123.12M` |
| match.homePoolFormat / match.drawPoolFormat / match.awayPoolFormat | 主胜 / 平局 / 客胜各选项奖池格式化显示 |
| match.myHomeBetFormat / match.myDrawBetFormat / match.myAwayBetFormat | 当前用户在主胜 / 平局 / 客胜各选项的累计下注额格式化显示,未下注为 `null` |
| homePool / drawPool / awayPool | 主胜 / 平局 / 客胜奖池明细 |
| myBets | 我的本场竞猜列表,字段同我的竞猜记录 |

View File

@ -1,23 +1,28 @@
# 页面创建信息 # 页面创建信息
## 0. 建页规则关联
- 页面改动、模块补充、接口对接、样式同步和文档同步,都需要先联系 `docs/活动页/建页/` 下的建页规则执行。
- 如果建页规则变化,先更新 `docs/活动页/建页/` 下的建页 md再同步当前页面的 `页面创建信息.md` 和 Vue 代码。
- 新建或更新页面时页面模块、div 结构、方法、接口处理都需要补充中文注释。
- 能使用内联样式处理的页面样式,优先使用内联样式。
- 页面根容器使用 `height: 100vh`,内部主内容垂直滚动并隐藏滚动条,页面任何模块都不展示可见滚动条。
- 接口返回的远程图片链接不要使用 `v-smart-img`,直接使用原生 `<img :src="接口字段">` 展示,不走本地图片缓存。
## 1. 基础信息 ## 1. 基础信息
- 页面类型Activities - 页面类型Activities
- 页面名称WorldCup - 页面名称WorldCup
- 页面目录src/views/Activities/WorldCup - 页面目录:`src/views/Activities/WorldCup`
- 参考页面: - 路由 path`/activities/world-cup`
- 备注:已生成 `index.vue` 可预览页面底座,目前完成头部和排行榜模块;排行榜接口和字段规则参考 `GulbenFestival` 的 personalRank / Ranking。 - 页面入口:`src/views/Activities/WorldCup/index.vue`
- 页面配置:`src/views/Activities/WorldCup/page.config.js`
- 专用接口:`src/api/worldCup.js`
- 接口说明:`src/views/Activities/WorldCup/世界杯接口说明.md`
- 参考页面:排行榜用户信息和等级展示规则参考 `src/views/Activities/GulbenFestival/index.vue``personalRank` / `Ranking` 模块。
## 2. 路由和权限 ## 2. 路由和权限
- route path/activities/world-cup
- route nameworld-cup
- 是否需要登录:否
- 是否加入 `src/config/security.js`:是
- 是否加入 `src/utils/permissionManager.js`:是
### 2.1 路由代码片段
```js ```js
{ {
path: '/activities/world-cup', path: '/activities/world-cup',
@ -27,253 +32,203 @@
} }
``` ```
### 2.2 安全白名单片段 - `src/config/security.js` 已加入 `/activities/world-cup`
- `src/utils/permissionManager.js` 已加入 `/activities/world-cup`
```js
'/activities/world-cup',
```
### 2.3 权限页面片段
```js
'/activities/world-cup', // WorldCup
```
## 3. OSS 图片目录 ## 3. OSS 图片目录
- OSS 相对路径Activities/WorldCup/ - OSS 相对路径:`Activities/WorldCup/`
- 本地开发目录public/oss/h5/Azizi/Activities/WorldCup/ - 本地开发目录:`public/oss/h5/Azizi/Activities/WorldCup/`
- 是否需要 Codex 自动创建本地开发目录:是 - 本地目录只作为开发期 OSS 图片镜像使用,发布时图片由用户上传到真实 OSS。
- 图片是否已放入本地目录:是
说明:本地开发目录只作为开发期 OSS 图片镜像使用,方便页面优先读取本地资源;发布时图片由你上传到真实 OSS不随项目提交 Git。新建本地目录时不生成 `.gitkeep`,空目录不被 Git 跟踪是正常现象。 ### 3.1 当前图片清单
### 3.1 多语言图片清单 | 图片名 | 备注 |
| --- | --- |
| bg2 | 页面最低层背景图,宽高和页面一致,直接拉伸填满 |
| bg | 头部背景图 |
| ruleBt | 头部右侧规则按钮 |
| ruleBg | 规则弹窗背景 |
| RankingMain | 排行榜主背景 |
| RankingBottomBorder | 排行榜底部边框 |
| RankingItem | 第四名以后的排行榜列表项背景 |
| frameTop1 | 第一名头像框 |
| frameTop2 | 第二名头像框 |
| frameTop3 | 第三名头像框 |
| giftBt | RankingMain 右上角奖励按钮 |
| rewardBg | 奖励弹窗背景 |
| showMoreBt | 展开 / 收起排行榜按钮图标 |
| myRankingBg | 我的排名贴底背景 |
| EventlistBg | 赛事列表模块背景 |
| matchBg | Predict Now 赛事列表项背景 |
| drawSign | Ended 平局押注项图片 |
| numPeople | 赛事列表右上角参与人数图标 |
| rightBt | 总奖池入口右侧箭头 |
| vip1-vip6 | VIP 等级图片 |
| userLevel1Bg-userLevel5Bg | 魅力等级背景图,图片名沿用旧素材命名 |
| wealthLevel1Bg-wealthLevel5Bg | 财富等级背景图 |
| 图片名 | 支持语言 | 备注 | ### 3.2 阻塞预加载图片
| --- | --- | --- |
| bg | 默认 | 头部主背景图 |
| bg2 | 默认 | 页面最低层背景图,拉伸铺满 100vh 视口 |
| RankingMain | 默认 | 排行榜主背景,内部上方展示前三名,下方展示 Show All Users |
| RankingItem | 默认 | 第四名以后排行榜列表项背景 |
| RankingBottomBorder | 默认 | 排行榜底边框 |
| frameTop1 | 默认 | 第一名头像框 |
| frameTop2 | 默认 | 第二名头像框 |
| frameTop3 | 默认 | 第三名头像框 |
| ruleBt | 默认 | 规则按钮 |
| showMoreBt | 默认 | 展开/收起排行榜按钮图标 |
| vip1-vip6 | 默认 | VIP 等级图片 |
| userLevel1Bg-userLevel5Bg | 默认 | 魅力等级背景 |
| wealthLevel1Bg-wealthLevel5Bg | 默认 | 财富等级背景 |
### 3.2 进入页面前同步预加载图片 `bg2``bg``ruleBt``ruleBg``EventlistBg``matchBg``drawSign``numPeople``rightBt``RankingMain``RankingItem``RankingBottomBorder``myRankingBg``giftBt``rewardBg``showMoreBt``frameTop1``frameTop2``frameTop3``vip1-vip6``userLevel1Bg-userLevel5Bg``wealthLevel1Bg-wealthLevel5Bg`
| 图片名 | 是否包含多语言 | 备注 | 页面进入时必须展示统一加载动画,阻塞预加载完成后再展示主内容。
| --- | --- | --- |
| bg2 | 否 | 页面最低层背景 |
| bg | 否 | 头部背景 |
| ruleBt | 否 | 规则按钮 |
| RankingMain | 否 | 排行榜主背景 |
| RankingItem | 否 | 排行榜列表项背景 |
| RankingBottomBorder | 否 | 排行榜底边框 |
| showMoreBt | 否 | 展开/收起按钮图标 |
| frameTop1 | 否 | 第一名头像框 |
| frameTop2 | 否 | 第二名头像框 |
| frameTop3 | 否 | 第三名头像框 |
| vip1-vip6 | 否 | VIP 等级图片 |
| userLevel1Bg-userLevel5Bg | 否 | 魅力等级背景 |
| wealthLevel1Bg-wealthLevel5Bg | 否 | 财富等级背景 |
### 3.3 进入页面后后台预加载图片 ## 4. 页面结构
| 图片名 | 是否包含多语言 | 备注 | ### 4.1 根容器
| --- | --- | --- |
| footballSign | 否 | 后续模块素材,当前未使用 |
| giftBt | 否 | 后续模块素材,当前未使用 |
| matchBg | 否 | 后续模块素材,当前未使用 |
| myRankingBg | 否 | 后续我的排名素材,当前未使用 |
| numPeople | 否 | 后续模块素材,当前未使用 |
| rewardBg | 否 | 后续奖励模块素材,当前未使用 |
| ruleBg | 否 | 后续规则弹窗素材,当前未接弹窗 |
| statusEndedfail | 否 | 后续状态素材,当前未使用 |
| statusEndedWin | 否 | 后续状态素材,当前未使用 |
| statusInProgress | 否 | 后续状态素材,当前未使用 |
| statusNotStarted | 否 | 后续状态素材,当前未使用 |
## 4. 模块配置 - 根容器背景色:`#101E14`
- 最底层背景图:`bg2`,使用绝对定位铺满 `100vw * 100vh`
- 主内容:`flex-direction: column``gap: 2vw`,内部滚动并隐藏滚动条。
- 底部固定展示我的排名模块,主内容底部保留 `20vw` 占位,防止遮挡。
当前页面按用户逐步补充需求生成,只处理已明确指定模块。 ### 4.2 头部模块
### 4.1 标签页模块 - 使用 `itemCenter` 承载 `bg`
- 通过 `contentStyle` 设置 padding。
- 内部右侧放置 `ruleBt` 图片按钮。
- 点击 `ruleBt` 打开规则弹窗。
```json ### 4.3 排行榜模块
{
"enabled": false,
"defaultValue": "",
"items": []
}
```
### 4.2 页面区块模块 - 使用 `RankingMain``itemCenter` 承载排行榜主内容。
- `RankingMain``contentStyle` 为 column`justify-content: space-between`
- `RankingMain` 内部右上角绝对定位 `giftBt`,宽度暂定 `10vw`,点击打开奖励弹窗。
- `RankingMain` 内部上方为前三名模块,下方为 `Show All Users` 展示更多模块。
- 前三名为同一行,使用 `direction: ltr`,排序为左二、中一、右三,并设置 `align-items: flex-end`
- 前三名私有组件为 `components/topUser.vue`,头像框分别使用 `frameTop1``frameTop2``frameTop3`
- `topUser` 内部从上到下展示头像、名称、ID、等级行整体使用 column 并水平居中。
- 等级行展示 VIP、魅力等级、财富等级展示规则参考 `GulbenFestival``personalRank` / `Ranking`
- 魅力等级字段使用接口返回的 `charmLevel`,当前页面暂不使用 `userLevel` 字段。
- 带 `{{ $t('user_id_prefix') }}` 的 ID 展示后面使用接口字段 `account`
- 第四名以后列表使用 `RankingItem`,列表项右侧贡献值前加 `/src/assets/icon/Azizi/coin.png`,金币和贡献值使用 flex 居中。
- `Show All Users` 只有在第四名以后存在数据时展示。
- 点击 `Show All Users` 后展示第四名以后列表和 `RankingBottomBorder`
- `Collapse List` 放在 `RankingBottomBorder` 下方,点击后隐藏第四名以后列表。
- `Collapse List` 下方保留 `20vw` 底部占位。
- 我的排名贴底模块使用 `myRankingBg``itemCenter`,固定在页面底部,不需要标签页判断。
```json ### 4.4 赛事列表模块
[
{
"name": "header",
"enabled": true,
"images": ["bg", "ruleBt"],
"layout": {
"container": "itemCenter",
"containerStyle": "width: 100vw",
"contentStyle": "padding: 87vw 0 0; align-items: flex-start; justify-content: flex-start; box-sizing: border-box;",
"ruleRowStyle": "width: 100%; display: flex; justify-content: flex-end",
"ruleButtonStyle": "width: 13.6vw; display: block"
},
"remark": "头部背景使用 bg内部只放贴右 ruleBt。"
},
{
"name": "ranking",
"enabled": true,
"images": [
"RankingMain",
"RankingItem",
"RankingBottomBorder",
"frameTop1",
"frameTop2",
"frameTop3",
"showMoreBt",
"vip1-vip6",
"userLevel1Bg-userLevel5Bg",
"wealthLevel1Bg-wealthLevel5Bg"
],
"layout": {
"sectionStyle": "position: relative; width: 100vw",
"backgroundStyle": "position: absolute; inset: 15vw 2vw 4vw; z-index: 0; background-color: #053126",
"rankingMainStyle": "width: 100vw",
"rankingMainContentStyle": "direction: ltr; padding: 0; flex-direction: column; align-items: center; justify-content: space-between; box-sizing: border-box;",
"topModuleStyle": "width: 100%; padding: 20vw 6vw 0; display: flex; align-items: flex-start; justify-content: center; box-sizing: border-box;",
"topThreeStyle": "width: 100%; height: 45vw; display: flex; align-items: flex-end; justify-content: space-between; gap: 1vw;",
"topUserOrder": "左二、中一、右三;第二名 width:24vw order:1第一名 width:26vw order:2第三名 width:24vw order:3",
"showMoreStyle": "width: 100%; padding: 0 0 4vw; position: relative; z-index: 2; display: flex; align-items: center; justify-content: center; gap: 2vw; color: #fff; font-size: 1.2em; font-weight: 800; line-height: 1;",
"showMoreImageStyle": "width: 10vw; display: block",
"rankingItemStyle": "width: 100vw; margin-top: -2vw",
"rankingItemContentStyle": "padding: 3vw 8vw 0; box-sizing: border-box; justify-content: space-between;",
"collapseStyle": "position: relative; z-index: 2; width: 100vw; padding: 2vw 0 4vw; display: flex; align-items: center; justify-content: center; gap: 2vw; color: #fff; font-size: 1.2em; font-weight: 800; line-height: 1;",
"collapseImageStyle": "width: 10vw; display: block; transform: scaleY(-1)",
"bottomBorderStyle": "position: relative; z-index: 1; display: block; width: 100vw; margin-top: -9vw; pointer-events: none"
},
"interaction": {
"defaultExpanded": false,
"showText": "Show All Users",
"collapseText": "Collapse List",
"showAction": "点击 Show All Users 后 showRankingList=true开始展示 RankingItem 列表。",
"collapseAction": "点击 Collapse List 后 showRankingList=false隐藏 RankingItem 列表。"
},
"remark": "RankingMain 内部从上到下分为前三名模块和展示更多模块;第四名以后列表默认隐藏。"
}
]
```
倒计时模块占位: - 赛事列表放在排行榜模块下方,使用 `EventlistBg``itemCenter`
- `EventlistBg` 内部样式为 column`justify-content: flex-start``align-items: center``gap: 2vw``padding: 0`
- 第一个小模块为文字切换按钮,左右各占 50%。
- 切换按钮文案为 `Predict Now!``Ended`
- 选中按钮使用金色边框和渐变背景,文本颜色 `#FFE083`
- 未选中按钮文本颜色 `#E9CB78`
- 第二个小模块为赛事列表区域,`flex: 1`,可垂直滚动并隐藏滚动条。
- 接口使用 `apiPostWorldCupMatchList({ size: 10, current: 1, tab })`
- `Predict Now!` 对应 `tab: 0``Ended` 对应 `tab: 1`
- 每次切换按钮都重新发送接口,暂不做上拉加载。
- 两个 tab 的数据必须分开承载:`predictMatchList` 保存 Predict Now 数据,`endedMatchList` 保存 Ended 数据,避免接口慢返回时两个列表数据混用。
- 请求保护使用每个 tab 独立的 request token只忽略同 tab 内更旧的响应,不影响另一个 tab 的缓存。
- Predict Now 使用 `matchBg``itemCenter` 渲染列表项。
- Ended 不使用 `matchBg`,直接使用普通 `div` 卡片样式渲染列表项。
- Predict Now 和 Ended 的单条列表都使用新的 column wrapper上方是原赛事卡片下方是总奖池入口`gap` 控制上下间距。
- 总奖池入口样式为金色描边渐变背景,内部一行展示 `TOTAL PRIZE POOL`、coin 图片、总奖池金额和右侧 `rightBt`;金额优先展示 `totalPoolFormat`,没有格式化字段时回退 `totalPool`
- 点击总奖池入口进入 `/activities/world-cup/event-detail?matchId=...`,详情页目录为 `src/views/Activities/WorldCup/EventDetail`
```json Predict Now 列表项规则:
{
"name": "countdown",
"enabled": false,
"deadline": "",
"images": ["timeBg"],
"fields": {
"endTime": "",
"remainingText": ""
},
"remark": "倒计时模块占位;本地图片清单尚未扫描到 timeBg待补充素材和截止时间后再启用。"
}
```
### 4.3 弹窗模块 - 每条记录使用 `matchBg``itemCenter`
- `matchBg` 内部从上往下分为状态行和赛事简要信息行。
- 状态行左侧按 `status``joined` 展示三种状态:
- `status === 1 && joined === true`:绿色小圆点加文本 `join`,绿色渐变背景。
- `status === 1 && joined === false`:文本 `Not Joined`,灰色渐变背景。
- 其他状态:文本 `Kick-off: HH:mm(UTC+8)`,时间来自 `kickoffTime`
- 状态行右侧展示 `numPeople` 图片和 `joinCount`
- 赛事简要信息行为三列:主国、平局、客国。
- 主国展示 `homeFlag``homeTeam``homePercent`
- 平局展示 `DRAW``drawPercent`,不放旗帜。
- 客国展示 `awayFlag``awayTeam``awayPercent`
- 主国和客国占比颜色为 `#71CE6E`,平局占比颜色为 `#A0A181`
```json Ended 列表项规则:
[]
```
## 5. 接口参数 - 每条记录使用普通 `div`,样式为 `border-radius: 10.682px; border: 0.89px solid #5D6948; background: linear-gradient(180deg, #0B2415 0%, #051C12 100%); backdrop-filter: blur(12.687064170837402px);`
- 内部从上往下分为状态行和押注选项行。
- 状态行左侧按参与结果展示三种状态:参与且中奖、参与未中奖、未参与。
- 参与且中奖展示 `+`、coin 图片和中奖金额;中奖金额优先使用 `myPayout`,并兼容 `payout` / `winAmount` 字段。
- 参与未中奖展示 `Better luck next time`
- 未参与展示 `Not Joined`
- 状态行右侧展示 `numPeople` 图片和 `joinCount`,和 Predict Now 保持一致。
- 押注选项行分为主方、平局、客方三列,仅做展示,不绑定押注点击。
- 未选中押注项使用默认金色边框渐变样式;选中押注项使用黄色描边和发光阴影样式。
- 选中态根据当前用户在各选项的累计下注额判断:`myHomeBetFormat``myDrawBetFormat``myAwayBetFormat` 不为空的押注项展示选中态,支持多个押注项同时选中。
- 主方展示 `homeFlag``homeTeam``homePercent`
- 平局展示 `drawSign``DRAW``drawPercent`
- 客方展示 `awayFlag``awayTeam``awayPercent`
- Ended 押注项旗帜图片来源不同:主客方 `homeFlag` / `awayFlag` 是接口返回链接,不使用 `v-smart-img`;平局 `drawSign` 是 OSS 素材,单独使用 `v-smart-img`
- 押注项金额信息分为上下两行,中间使用 `background: #E9D089` 分割线;上方展示用户累计下注额 `myHomeBetFormat` / `myDrawBetFormat` / `myAwayBetFormat`,下方展示选项奖池 `homePoolFormat` / `drawPoolFormat` / `awayPoolFormat`,空值展示 `0`
```json ### 4.5 弹窗模块
{
"activityId": "",
"activityCode": "",
"activityType": 19,
"rankingActivityType": "",
"rewardActivityType": "",
"otherParams": {}
}
```
## 6. 组件需求 - 规则弹窗和奖励弹窗都使用公共组件 `src/components/MaskLayer.vue`
- 规则弹窗使用 `ruleBg``itemCenter`,弹窗宽度暂定 `100vw`
- 奖励弹窗使用 `rewardBg``itemCenter`,弹窗宽度暂定 `100vw`
- `ruleBg``rewardBg` 图片高度偏长,弹窗内容不做垂直居中,外层保留顶部和底部空白区域,点击空白区域退出弹窗。
需要哪一个就填“是”,不需要或没填写的初版页面先不接。 ## 5. 接口说明
```json - `apiPostWorldCupMatchList(data)`:比赛列表,`POST /activity/worldcup/match/list`
[ - `apiPostWorldCupBet(data)`:押注,`POST /activity/worldcup/bet`
{ - `apiPostWorldCupMyBetList(data)`:我的竞猜记录,`POST /activity/worldcup/my/bet/list`
"name": "BackgroundLayer", - `apiPostWorldCupRankList()`:排行榜,`POST /activity/worldcup/rank/list`,接口无业务参数但 POST 仍传 `{}`
"enabled": "是", - `apiGetWorldCupMatchDetail(matchId)`:比赛详情,`GET /activity/worldcup/match/detail`
"remark": "长背景分段" - 详细字段和返回示例见 `src/views/Activities/WorldCup/世界杯接口说明.md`
},
{
"name": "itemCenter",
"enabled": "是",
"remark": "图片容器"
},
{
"name": "topUser / TopUser",
"enabled": "是",
"remark": "前三名用户展示"
},
{
"name": "Barrage",
"enabled": "",
"remark": "弹幕"
},
{
"name": "ActivityTabs",
"enabled": "",
"remark": "标签页"
},
{
"name": "ActivityLotteryGrid",
"enabled": "",
"remark": "抽奖格子"
}
]
```
## 7. 建页后记录 ### 5.1 排行榜字段映射
### 7.1 已完成 - `rankingList` 映射为页面排行榜列表。
- `currentUserRank` 映射为我的排名贴底模块。
- `rank` 使用接口字段 `rank`
- 用户资料点击使用接口字段 `userId`,接口没有 `rawUserId`
- 头像使用 `avatar`
- 昵称使用 `nickname`
- ID 展示使用 `account`
- 页面贡献值 `amount` 使用 `quantity`
- `quantityNumber` 原样保留。
- 魅力等级使用 `charmLevel`
- 财富等级使用 `wealthLevel`
- VIP 使用 `vipLevelName`
- 创建页面目录:`src/views/Activities/WorldCup/` ### 5.2 赛事列表字段映射
- 创建专属页面信息文件:`src/views/Activities/WorldCup/页面创建信息.md`
- 创建初始配置文件:`src/views/Activities/WorldCup/page.config.js`
- 创建本地开发图片目录:`public/oss/h5/Azizi/Activities/WorldCup/`
- 写入路由:`/activities/world-cup`
- 写入 `src/config/security.js` 白名单
- 写入 `src/utils/permissionManager.js` 活动页权限列表
- 创建并持续更新 `index.vue` 可预览页面
- 创建 `components/topUser.vue` 私有前三名组件
- 完成统一加载动画、页面背景、头部模块、排行榜前三名、排行榜接口和展开/收起列表交互
### 7.2 未填写所以跳过 - `matchId`:比赛 ID。
- `homeTeam` / `awayTeam`:主国 / 客国名称。
- `homeFlag` / `awayFlag`:主国 / 客国旗帜,属于接口远程图片,直接用原生 `<img>` 展示,不加 `v-smart-img`
- `kickoffTime`:开赛时间戳,页面格式化为 `HH:mm(UTC+8)`
- `status`:比赛状态,`0` 未开赛,`1` 比赛中,`2` 已结束。
- `joined`:当前用户是否已参与本场竞猜。
- `joinCount`:参与人次。
- `homePercent` / `drawPercent` / `awayPercent`:主胜 / 平局 / 客胜占比。
- 规则弹窗暂未接点击事件和弹窗内容。 ## 6. 建页后记录
- 赛事、奖励、任务、我的排名等素材暂未生成对应业务模块。
### 7.3 待你微调 ### 6.1 已完成
- 继续按预览效果微调排行榜位置、间距和展开/收起按钮位置。 - 创建页面目录 `src/views/Activities/WorldCup/`
- 后续补规则弹窗、赛事、奖励、任务、我的排名等模块时继续先更新本 md再同步 Vue。 - 创建页面信息文档 `src/views/Activities/WorldCup/页面创建信息.md`
- 创建页面配置 `src/views/Activities/WorldCup/page.config.js`
- 创建世界杯接口文件 `src/api/worldCup.js`
- 创建接口说明文档 `src/views/Activities/WorldCup/世界杯接口说明.md`
- 创建本地开发图片目录 `public/oss/h5/Azizi/Activities/WorldCup/`
- 写入路由 `/activities/world-cup`
- 写入安全白名单和活动页权限列表。
- 完成统一加载动画、页面背景、头部模块、排行榜前三名、排行榜接口、展开 / 收起列表交互。
- 完成我的排名贴底模块、奖励弹窗入口、规则弹窗。
- 完成赛事列表初版、Predict Now 列表项、赛事列表 tab 数据分开承载。
- 完成 Predict Now / Ended 列表项下方总奖池入口,并接入赛事详情页路由。
- 创建赛事详情页占位目录 `src/views/Activities/WorldCup/EventDetail/` 和页面创建信息 md。
### 7.4 验证结果 ### 6.2 后续补充
- Ended 列表已完成当前接口字段下的展示结构,后续如果接口补充用户押注选项、派彩金额、单项奖池和押注金额,只需要更新字段映射。
- 比赛详情、下注弹窗、奖励弹窗内容后续补充。
- 规则弹窗内容后续补充。
### 6.3 验证记录
- `npm run build`:已运行并通过。 - `npm run build`:已运行并通过。
- 备注:构建中存在项目原有 warning`secondBg.png` 运行时解析提示,以及 `src/utils/http.js` 动静态混用 chunk 提示。 - 构建中存在项目原有 warning`secondBg.png` 运行时解析提示,以及 `src/utils/http.js` 动静态混用 chunk 提示。