864 lines
32 KiB
Vue
864 lines
32 KiB
Vue
<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%; min-height: 53vw; max-height: 54vw"
|
||
: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.1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
"
|
||
>
|
||
<img
|
||
v-if="betOption.isOssImage"
|
||
v-smart-img.noFade
|
||
:src="betOption.flagUrl"
|
||
alt=""
|
||
style="display: block; width: 64%; aspect-ratio: 1 / 1; object-fit: contain"
|
||
/>
|
||
<img
|
||
v-else
|
||
:src="betOption.flagUrl"
|
||
alt=""
|
||
style="display: block; width: 68%; aspect-ratio: 1 / 1; object-fit: contain"
|
||
/>
|
||
<!-- 已结束胜方标识框:根据 result 字段叠加在主胜、平局或客胜对应图片上方 -->
|
||
<img
|
||
v-if="isDetailBetOptionWinner(betOption.option)"
|
||
v-smart-img.noFade
|
||
:src="pngUrl('frameWinner')"
|
||
alt=""
|
||
style="
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 3;
|
||
display: block;
|
||
width: 100%;
|
||
object-fit: contain;
|
||
pointer-events: none;
|
||
"
|
||
/>
|
||
</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: 0vw;
|
||
margin-top: 1vw;
|
||
"
|
||
>
|
||
<!-- 选项奖池金额:使用详情接口格式化字段优先展示 -->
|
||
<div
|
||
style="display: flex; align-items: center; justify-content: center; padding: 4px"
|
||
>
|
||
<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 style="width: 100%; height: 1px; background: #e9d089"></div>
|
||
<!-- 用户下注金额:未押注时展示 0 -->
|
||
<div
|
||
style="display: flex; align-items: center; justify-content: center; padding: 4px"
|
||
>
|
||
<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>
|
||
</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 { connectToApp } from '@/utils/appConnector.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',
|
||
'frameWinner',
|
||
'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
|
||
}
|
||
|
||
// 已结束胜方标识:只有已结束赛事根据 result 字段给对应押注项叠加 frameWinner。
|
||
const isDetailBetOptionWinner = (option) => {
|
||
return Number(matchInfo.value.status) === 2 && Number(matchInfo.value.result) === Number(option)
|
||
}
|
||
|
||
// 点击押注对象:仅未开始赛事允许选择押注对象,默认不选中任何项。
|
||
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
|
||
}
|
||
}
|
||
|
||
// 连接 App 并设置接口请求头:详情页所有接口请求必须在 connectToApp 成功后执行。
|
||
const connectToAppHandler = async () => {
|
||
await connectToApp(() => {
|
||
completePreloading()
|
||
})
|
||
}
|
||
|
||
// 押注接口占位:后续第三模块接入押注按钮时统一调用该方法。
|
||
const submitWorldCupBet = async (betOption, betAmount) => {
|
||
if (!matchId.value) return
|
||
return apiPostWorldCupBet({
|
||
matchId: String(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)
|
||
connectToAppHandler()
|
||
})
|
||
|
||
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>
|