feat: add match handicap comparison panel
This commit is contained in:
@@ -1,72 +1,70 @@
|
||||
<template>
|
||||
<view class="match-odds-detail">
|
||||
<scroll-view scroll-x class="match-odds-detail__tabs" show-scrollbar="false">
|
||||
<view class="match-odds-detail__tabs-inner">
|
||||
<view v-for="item in platformOptions" :key="item.key" class="match-odds-detail__tab"
|
||||
:class="{ 'match-odds-detail__tab--active': sourceSite === item.key }"
|
||||
@tap="switchPlatform(item.key)">
|
||||
<text>{{ item.label }}</text>
|
||||
</view>
|
||||
<view class="odds-board">
|
||||
<view class="odds-board__heading">
|
||||
<view>
|
||||
<text class="odds-board__title">全网盘口对比</text>
|
||||
<text class="odds-board__hint">数据仅供参考 · 实时更新</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="loading" class="match-odds-detail__state">
|
||||
<u-loading mode="circle" />
|
||||
<text>赔率加载中...</text>
|
||||
<text class="odds-board__time">{{ latestUpdateText }}</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loadError" class="match-odds-detail__state">
|
||||
<view class="market-tabs">
|
||||
<view v-for="item in marketTabs" :key="item.key" class="market-tabs__item"
|
||||
:class="{ 'market-tabs__item--active': activeMarket === item.key }"
|
||||
@tap="activeMarket = item.key">
|
||||
<text>{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="odds-board__state">
|
||||
<u-loading mode="circle" color="#1769ff" />
|
||||
<text>盘口加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="loadError" class="odds-board__state">
|
||||
<text>{{ loadError }}</text>
|
||||
<u-button size="mini" type="primary" @click="retryFetch">重新加载</u-button>
|
||||
</view>
|
||||
|
||||
<view v-else-if="platforms.length" class="match-odds-detail__list">
|
||||
<view v-for="platform in platforms"
|
||||
:key="`${platform.platform_key}-${platform.source_match_id || platform.update_time || 0}`"
|
||||
class="match-odds-platform">
|
||||
<view class="match-odds-platform__head">
|
||||
<view class="match-odds-platform__name">
|
||||
<text>{{ platform.platform_name }}</text>
|
||||
<text v-if="platform.show_type_text" class="match-odds-platform__tag">
|
||||
{{ platform.show_type_text }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="match-odds-platform__update">{{ formatUpdateTime(platform.update_time) }}</text>
|
||||
<view v-else-if="rows.length" class="odds-table-wrap">
|
||||
<view class="odds-table">
|
||||
<view class="odds-table__row odds-table__row--head">
|
||||
<text>来源</text>
|
||||
<text>主队</text>
|
||||
<text>盘口</text>
|
||||
<text>客队</text>
|
||||
<text>更新时间</text>
|
||||
</view>
|
||||
|
||||
<view v-for="market in platform.markets" :key="`${platform.source_match_id}-${market.label}`"
|
||||
class="match-odds-market">
|
||||
<view class="match-odds-market__title">
|
||||
<text>{{ market.label }}</text>
|
||||
<text v-if="market.line" class="match-odds-market__line">{{ market.line }}</text>
|
||||
</view>
|
||||
<view class="match-odds-market__values">
|
||||
<view v-for="item in market.values" :key="`${market.label}-${item.label}`"
|
||||
class="match-odds-value">
|
||||
<text class="match-odds-value__label">{{ item.label }}</text>
|
||||
<text class="match-odds-value__number">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-for="row in rows" :key="row.key" class="odds-table__row">
|
||||
<text class="odds-table__source">{{ row.source }}</text>
|
||||
<text :class="['odds-table__odd', oddsTone(row.home)]">{{ row.home || '--' }} <i>{{ oddsArrow(row.home) }}</i></text>
|
||||
<text class="odds-table__line">{{ row.line || '--' }}</text>
|
||||
<text :class="['odds-table__odd', oddsTone(row.away)]">{{ row.away || '--' }} <i>{{ oddsArrow(row.away) }}</i></text>
|
||||
<text class="odds-table__update">{{ formatUpdateTime(row.updateTime) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="odds-board__more" @tap="showMore = !showMore">
|
||||
<text>{{ showMore ? '收起盘口变化' : '查看盘口变化' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="odds-board__state odds-board__state--empty">
|
||||
<text>暂无{{ activeMarketLabel }}数据</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="match-odds-detail__state">
|
||||
<u-empty text="暂无赔率" mode="data" />
|
||||
<view v-if="showMore" class="odds-trend">
|
||||
<view class="odds-trend__head">
|
||||
<text>近24小时变化</text>
|
||||
<text class="odds-trend__empty">暂无历史盘口数据</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
getMatchOdds,
|
||||
type WorldCupOddsPlatform,
|
||||
type MatchOddsMatchItem,
|
||||
} from '@/api/match'
|
||||
import { getMatchOdds, type WorldCupOddsMatchItem, type WorldCupOddsPlatform } from '@/api/match'
|
||||
import { getLocalMatchDate } from '@/utils/match-time'
|
||||
|
||||
type PlatformOption = { key: string; label: string }
|
||||
type MarketKey = 'europe' | 'handicap' | 'total'
|
||||
type OddsRow = { key: string; source: string; home: string; line: string; away: string; updateTime: number }
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
homeTeam?: string
|
||||
@@ -74,306 +72,118 @@ const props = withDefaults(defineProps<{
|
||||
matchTime?: number | string
|
||||
competitionId?: number | string
|
||||
leagueName?: string
|
||||
}>(), {
|
||||
homeTeam: '',
|
||||
awayTeam: '',
|
||||
matchTime: 0,
|
||||
competitionId: 0,
|
||||
leagueName: '',
|
||||
})
|
||||
}>(), { homeTeam: '', awayTeam: '', matchTime: 0, competitionId: 0, leagueName: '' })
|
||||
|
||||
const defaultPlatformOptions: PlatformOption[] = [
|
||||
{ key: '', label: '全部平台' },
|
||||
{ key: 'hg', label: '皇冠' },
|
||||
{ key: 'titan007', label: '球探' },
|
||||
const marketTabs: { key: MarketKey; label: string }[] = [
|
||||
{ key: 'europe', label: '欧赔' },
|
||||
{ key: 'handicap', label: '亚盘' },
|
||||
{ key: 'total', label: '大小球' },
|
||||
]
|
||||
|
||||
const oddsList = ref<MatchOddsMatchItem[]>([])
|
||||
const platformOptions = ref<PlatformOption[]>([...defaultPlatformOptions])
|
||||
const sourceSite = ref('')
|
||||
const activeMarket = ref<MarketKey>('handicap')
|
||||
const oddsList = ref<WorldCupOddsMatchItem[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const loadError = ref('')
|
||||
const showMore = ref(false)
|
||||
const requestSeq = ref(0)
|
||||
|
||||
const normalizePlatformLabel = (key: string, label: string) => {
|
||||
return key === 'hg' ? '皇冠' : label
|
||||
}
|
||||
|
||||
const platforms = computed<WorldCupOddsPlatform[]>(() => {
|
||||
const result: WorldCupOddsPlatform[] = []
|
||||
const seen = new Set<string>()
|
||||
oddsList.value.forEach((match) => {
|
||||
const matchPlatforms = match.platforms || []
|
||||
matchPlatforms.forEach((platform) => {
|
||||
const key = `${platform.platform_key}-${platform.source_match_id || ''}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
result.push({
|
||||
...platform,
|
||||
platform_name: normalizePlatformLabel(platform.platform_key, platform.platform_name),
|
||||
})
|
||||
})
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const matchDate = computed(() => {
|
||||
const date = getLocalMatchDate(props.matchTime, {
|
||||
competition_id: props.competitionId,
|
||||
league_name: props.leagueName,
|
||||
})
|
||||
const date = getLocalMatchDate(props.matchTime, { competition_id: props.competitionId, league_name: props.leagueName })
|
||||
if (!date) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const mergePlatformOptions = (options: unknown) => {
|
||||
const map = new Map<string, PlatformOption>()
|
||||
defaultPlatformOptions.forEach((item) => map.set(item.key, item))
|
||||
if (Array.isArray(options)) {
|
||||
options.forEach((item: any) => {
|
||||
const key = String(item?.key || '').trim()
|
||||
const label = String(item?.label || '').trim()
|
||||
if (key && label) map.set(key, { key, label: normalizePlatformLabel(key, label) })
|
||||
})
|
||||
const activeMarketLabel = computed(() => marketTabs.find((item) => item.key === activeMarket.value)?.label || '盘口')
|
||||
const platformList = computed<WorldCupOddsPlatform[]>(() => oddsList.value.flatMap((item) => item.platforms || []))
|
||||
const latestUpdate = computed(() => platformList.value.reduce((latest, item) => Math.max(latest, Number(item.update_time || 0)), 0))
|
||||
const latestUpdateText = computed(() => latestUpdate.value ? `更新 ${formatUpdateTime(latestUpdate.value)}` : '实时更新')
|
||||
|
||||
const rows = computed<OddsRow[]>(() => platformList.value.map((platform, index) => {
|
||||
const marketLabel = activeMarket.value === 'europe' ? '独赢' : activeMarket.value === 'handicap' ? '让球' : '大小球'
|
||||
const market = (platform.markets || []).find((item) => item.label === marketLabel)
|
||||
const values = market?.values || []
|
||||
return {
|
||||
key: `${platform.platform_key}-${platform.source_match_id || index}-${activeMarket.value}`,
|
||||
source: normalizePlatformLabel(platform.platform_key, platform.platform_name),
|
||||
home: values[0]?.value || '',
|
||||
line: market?.line || '',
|
||||
away: values[activeMarket.value === 'europe' ? 2 : 1]?.value || '',
|
||||
updateTime: Number(platform.update_time || 0),
|
||||
}
|
||||
platformOptions.value = Array.from(map.values())
|
||||
}).filter((row) => row.home || row.away))
|
||||
|
||||
const normalizePlatformLabel = (key: string, label?: string) => key === 'hg' ? '皇冠' : (label || key || '未知来源')
|
||||
const oddsTone = (value: string) => {
|
||||
if (!value) return 'odds-table__odd--muted'
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) && number >= 0.95 ? 'odds-table__odd--up' : 'odds-table__odd--down'
|
||||
}
|
||||
const oddsArrow = (value: string) => value ? (Number(value) >= 0.95 ? '▲' : '▼') : '—'
|
||||
const formatUpdateTime = (timestamp: number) => {
|
||||
if (!timestamp) return '--'
|
||||
const date = new Date(timestamp * 1000)
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const fetchOdds = async (force = false) => {
|
||||
if (!force && (loaded.value || loading.value)) return
|
||||
if (!props.homeTeam.trim() || !props.awayTeam.trim() || !matchDate.value) {
|
||||
oddsList.value = []
|
||||
if (!props.homeTeam?.trim() || !props.awayTeam?.trim() || !matchDate.value) {
|
||||
loaded.value = true
|
||||
oddsList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const seq = requestSeq.value + 1
|
||||
requestSeq.value = seq
|
||||
const seq = ++requestSeq.value
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const data = await getMatchOdds({
|
||||
home_team: props.homeTeam.trim(),
|
||||
away_team: props.awayTeam.trim(),
|
||||
match_date: matchDate.value,
|
||||
source_site: sourceSite.value || undefined,
|
||||
limit: 120,
|
||||
})
|
||||
const data = await getMatchOdds({ home_team: props.homeTeam.trim(), away_team: props.awayTeam.trim(), match_date: matchDate.value, limit: 120 })
|
||||
if (seq !== requestSeq.value) return
|
||||
oddsList.value = Array.isArray(data?.list) ? data.list : []
|
||||
mergePlatformOptions(data?.platform_options)
|
||||
loaded.value = true
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq.value) return
|
||||
console.error('获取比赛赔率失败', error)
|
||||
console.error('获取比赛盘口失败', error)
|
||||
loadError.value = '盘口加载失败,请稍后重试'
|
||||
oddsList.value = []
|
||||
loaded.value = true
|
||||
loadError.value = '赔率加载失败,请稍后重试'
|
||||
} finally {
|
||||
if (seq === requestSeq.value) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchPlatform = (platformKey: string) => {
|
||||
if (sourceSite.value === platformKey) return
|
||||
sourceSite.value = platformKey
|
||||
const retryFetch = () => { loaded.value = false; void fetchOdds(true) }
|
||||
watch(() => [props.homeTeam, props.awayTeam, props.matchTime, props.competitionId, props.leagueName], () => {
|
||||
loaded.value = false
|
||||
oddsList.value = []
|
||||
void fetchOdds(true)
|
||||
}
|
||||
|
||||
const retryFetch = () => {
|
||||
loaded.value = false
|
||||
void fetchOdds(true)
|
||||
}
|
||||
|
||||
const formatUpdateTime = (timestamp?: number) => {
|
||||
const value = Number(timestamp || 0)
|
||||
if (!value) return ''
|
||||
const date = new Date(value * 1000)
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.homeTeam, props.awayTeam, props.matchTime, props.competitionId, props.leagueName],
|
||||
() => {
|
||||
loaded.value = false
|
||||
oddsList.value = []
|
||||
loadError.value = ''
|
||||
void fetchOdds(true)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void fetchOdds()
|
||||
})
|
||||
onMounted(() => void fetchOdds())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.match-odds-detail {
|
||||
min-height: 100%;
|
||||
padding: 20rpx 24rpx 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.match-odds-detail__tabs {
|
||||
width: 100%;
|
||||
margin-bottom: 20rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.match-odds-detail__tabs-inner {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.match-odds-detail__tab {
|
||||
position: relative;
|
||||
flex: 1 0 112rpx;
|
||||
min-width: 112rpx;
|
||||
height: 64rpx;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
color: #64748b;
|
||||
font-size: 24rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.match-odds-detail__tab--active {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: 32rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
background: #2563eb;
|
||||
content: '';
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.match-odds-detail__state {
|
||||
min-height: 360rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18rpx;
|
||||
color: #94a3b8;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.match-odds-detail__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.match-odds-platform {
|
||||
padding: 22rpx;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 8rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.match-odds-platform__head,
|
||||
.match-odds-platform__name,
|
||||
.match-odds-market__title,
|
||||
.match-odds-market__values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.match-odds-platform__head {
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.match-odds-platform__name {
|
||||
min-width: 0;
|
||||
gap: 12rpx;
|
||||
color: #0f172a;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.match-odds-platform__tag {
|
||||
padding: 4rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-size: 20rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.match-odds-platform__update {
|
||||
flex-shrink: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.match-odds-market {
|
||||
padding: 16rpx 0;
|
||||
border-top: 1rpx solid #f1f5f9;
|
||||
}
|
||||
|
||||
.match-odds-market__title {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
color: #334155;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.match-odds-market__line {
|
||||
color: #2563eb;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.match-odds-market__values {
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.match-odds-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 86rpx;
|
||||
padding: 12rpx 8rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 6rpx;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.match-odds-value__label {
|
||||
color: #64748b;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
|
||||
.match-odds-value__number {
|
||||
color: #0f172a;
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.odds-board { min-height: 100%; padding: 28rpx 24rpx 40rpx; box-sizing: border-box; background: #f8fafc; }
|
||||
.odds-board__heading { display: flex; align-items: flex-end; justify-content: space-between; margin: 0 4rpx 24rpx; }
|
||||
.odds-board__title { display: block; color: #111827; font-size: 34rpx; font-weight: 700; }
|
||||
.odds-board__hint, .odds-board__time { color: #9aa2ad; font-size: 22rpx; }
|
||||
.odds-board__hint { display: block; margin-top: 8rpx; }
|
||||
.market-tabs { display: flex; gap: 16rpx; margin-bottom: 20rpx; }
|
||||
.market-tabs__item { flex: 1; height: 70rpx; display: flex; align-items: center; justify-content: center; border-radius: 18rpx; background: #f1f3f6; color: #707781; font-size: 27rpx; }
|
||||
.market-tabs__item--active { background: #1769ff; color: #fff; font-weight: 600; box-shadow: 0 10rpx 24rpx rgba(23, 105, 255, .18); }
|
||||
.odds-table-wrap { overflow: hidden; border: 1rpx solid #e5e9ef; border-radius: 18rpx; background: #fff; }
|
||||
.odds-table { width: 100%; }
|
||||
.odds-table__row { min-height: 72rpx; display: grid; grid-template-columns: 1.05fr 1.1fr 1.25fr 1.1fr .9fr; align-items: center; border-top: 1rpx solid #edf0f4; color: #343941; font-size: 23rpx; text-align: center; }
|
||||
.odds-table__row:first-child { border-top: 0; }
|
||||
.odds-table__row--head { min-height: 68rpx; background: #f8fafc; color: #69717d; font-size: 22rpx; }
|
||||
.odds-table__source { font-weight: 600; }
|
||||
.odds-table__line { color: #222831; font-size: 24rpx; }
|
||||
.odds-table__odd { font-size: 28rpx; font-weight: 600; }
|
||||
.odds-table__odd i { margin-left: 4rpx; font-style: normal; font-size: 17rpx; }
|
||||
.odds-table__odd--up { color: #e53935; }
|
||||
.odds-table__odd--down { color: #36a653; }
|
||||
.odds-table__odd--muted, .odds-table__update { color: #9aa2ad; }
|
||||
.odds-table__update { font-size: 21rpx; }
|
||||
.odds-board__more { margin: 18rpx; height: 66rpx; display: flex; align-items: center; justify-content: center; border: 2rpx solid #1769ff; border-radius: 34rpx; color: #1769ff; font-size: 27rpx; }
|
||||
.odds-board__state { min-height: 420rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18rpx; color: #97a0ad; font-size: 25rpx; }
|
||||
.odds-trend { margin-top: 20rpx; padding: 24rpx; border: 1rpx solid #e5e9ef; border-radius: 18rpx; background: #fff; }
|
||||
.odds-trend__head { display: flex; justify-content: space-between; color: #222831; font-size: 27rpx; font-weight: 600; }
|
||||
.odds-trend__empty { color: #9aa2ad; font-size: 22rpx; font-weight: 400; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="match-detail">
|
||||
<match-detail-header :status-bar-height="statusBarHeight" title="赛事详情"
|
||||
:subtitle="match.league_name || '赛事信息加载中'" @back="goBack" @share="showSharePopup = true" />
|
||||
<match-detail-header :status-bar-height="statusBarHeight" :title="matchDetailTitle" @back="goBack"
|
||||
@share="showSharePopup = true" />
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view class="detail-loading" v-if="loading">
|
||||
@@ -11,69 +11,8 @@
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<scroll-view v-else scroll-y class="detail-content" :style="{ height: contentHeight + 'px' }">
|
||||
<!-- 比赛信息卡片:进行中/已结束 -->
|
||||
<view class="match-info-wrap" v-if="match.status !== 0">
|
||||
<match-card :match="match" />
|
||||
</view>
|
||||
|
||||
<!-- 比赛信息卡片:未开始 -->
|
||||
<view class="match-info-wrap" v-else>
|
||||
<view class="upcoming-card">
|
||||
<view class="upcoming-card__header">
|
||||
<view class="upcoming-card__tags">
|
||||
<text class="upcoming-card__league">{{ match.league_name }}</text>
|
||||
<view v-if="match.round_name" class="upcoming-card__tag upcoming-card__tag--blue">
|
||||
<text>{{ match.round_name }}</text>
|
||||
</view>
|
||||
<view v-if="match.is_hot" class="upcoming-card__hot">
|
||||
<text>热</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="upcoming-card__teams">
|
||||
<view class="upcoming-card__team">
|
||||
<image v-if="match.home_icon" class="upcoming-card__icon" :src="match.home_icon"
|
||||
mode="aspectFit" />
|
||||
<text class="upcoming-card__name">{{ match.home_team }}</text>
|
||||
</view>
|
||||
<view class="upcoming-card__center">
|
||||
<text class="upcoming-card__score">0</text>
|
||||
<text class="upcoming-card__vs">VS</text>
|
||||
<text class="upcoming-card__score">0</text>
|
||||
</view>
|
||||
<view class="upcoming-card__team">
|
||||
<image v-if="match.away_icon" class="upcoming-card__icon" :src="match.away_icon"
|
||||
mode="aspectFit" />
|
||||
<text class="upcoming-card__name">{{ match.away_team }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="upcoming-card__divider"></view>
|
||||
<view class="upcoming-card__footer">
|
||||
<view class="upcoming-card__status">
|
||||
<text>未开始</text>
|
||||
</view>
|
||||
<text class="upcoming-card__time">{{ formatMatchTime(match.match_time) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="liveList.length" class="live-entrance">
|
||||
<view class="live-entrance__player-card">
|
||||
<view class="live-entrance__player-header">
|
||||
<view class="live-entrance__player-title-wrap">
|
||||
<text class="live-entrance__player-title">{{ activeLiveLineTitle }}</text>
|
||||
</view>
|
||||
<view class="live-entrance__player-actions">
|
||||
<u-button v-if="activeLivePlayUrl" size="mini" shape="circle" plain
|
||||
@click.stop="openLiveFullscreen">
|
||||
全屏播放
|
||||
</u-button>
|
||||
<u-button size="mini" shape="circle" plain @click.stop="toggleLiveLineSelector">
|
||||
{{ showLiveLineSelector ? '收起线路' : '切换线路' }}
|
||||
</u-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activeLivePlayUrl" class="live-entrance__player-wrap">
|
||||
<video id="match-live-player" :key="activeLivePlayerKey" class="live-entrance__player"
|
||||
:src="activeLivePlayUrl" :poster="match.home_icon || match.away_icon || ''" :autoplay="true"
|
||||
@@ -81,6 +20,18 @@
|
||||
:show-fullscreen-btn="true" :page-gesture="true" object-fit="contain"
|
||||
@play="handleLivePlayerPlay" @loadedmetadata="handleLivePlayerLoaded"
|
||||
@waiting="handleLivePlayerWaiting" @error="handleLivePlayerError" />
|
||||
<view v-if="match.status === 1" class="live-entrance__live-status">
|
||||
<text>直播中</text>
|
||||
<text class="live-entrance__live-minute">{{ match.current_minute || '进行中' }}</text>
|
||||
</view>
|
||||
<view v-if="activeLivePlayUrl" class="live-entrance__fullscreen" @tap.stop="openLiveFullscreen">
|
||||
<text class="live-entrance__fullscreen-icon">⛶</text>
|
||||
<text>全屏</text>
|
||||
</view>
|
||||
<view v-if="livePlayerLoading" class="live-entrance__player-loading">
|
||||
<u-loading mode="circle" color="#ffffff" />
|
||||
<text class="live-entrance__player-loading-text">正在连接直播</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="live-entrance__player-empty">
|
||||
<text class="live-entrance__player-empty-title">当前线路暂无可用直播放流</text>
|
||||
@@ -91,27 +42,32 @@
|
||||
</u-button>
|
||||
</view>
|
||||
|
||||
<text v-if="activeLiveError" class="live-entrance__player-error">{{ activeLiveError }}</text>
|
||||
|
||||
<view v-if="showLiveLineSelector" class="live-entrance__selector">
|
||||
<view v-for="(item, index) in resolvedLiveList" :key="item.lineKey"
|
||||
class="live-entrance__selector-item"
|
||||
:class="{ 'live-entrance__selector-item--active': item.lineKey === activeLiveLineKey }"
|
||||
@tap="selectLiveLine(item)">
|
||||
<view class="live-entrance__selector-main">
|
||||
<view class="live-entrance__selector-index">
|
||||
<text>{{ index + 1 }}</text>
|
||||
</view>
|
||||
<view class="live-entrance__selector-content">
|
||||
<text class="live-entrance__selector-title">{{ item.displayTitle }}</text>
|
||||
<text class="live-entrance__selector-desc">{{ item.metaText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="live-entrance__selector-tag">
|
||||
{{ item.lineKey === activeLiveLineKey ? '当前播放' : item.hasDirectPlay ? '点击切换' : '源站兜底' }}
|
||||
</text>
|
||||
<view class="live-entrance__player-header">
|
||||
<view class="live-entrance__player-title-wrap">
|
||||
<text class="live-entrance__eyebrow">{{ resolvedLiveList.length > 1 ? `直播间 · ${resolvedLiveList.length} 路` : '直播间' }}</text>
|
||||
<text class="live-entrance__player-title">{{ activeLiveLineTitle }}</text>
|
||||
<text v-if="activeLiveMetaText" class="live-entrance__player-meta">{{ activeLiveMetaText }}</text>
|
||||
</view>
|
||||
<view v-if="activeLiveSourceUrl" class="live-entrance__source-action" @tap.stop="openActiveLiveSource">
|
||||
<text>源站</text>
|
||||
<text>›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text v-if="activeLiveError" class="live-entrance__player-error">{{ activeLiveError }}</text>
|
||||
|
||||
<scroll-view v-if="resolvedLiveList.length > 1" scroll-x class="live-entrance__room-tabs"
|
||||
show-scrollbar="false">
|
||||
<view class="live-entrance__room-tabs-inner">
|
||||
<view v-for="(item, index) in resolvedLiveList" :key="item.lineKey"
|
||||
class="live-entrance__room-tab"
|
||||
:class="{ 'live-entrance__room-tab--active': item.lineKey === activeLiveLineKey }"
|
||||
@tap="selectLiveLine(item)">
|
||||
<text class="live-entrance__room-index">{{ index + 1 }}</text>
|
||||
<text class="live-entrance__room-name">{{ item.anchor_name || item.displayTitle }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -136,6 +92,33 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-scoreboard">
|
||||
<view class="detail-scoreboard__team">
|
||||
<image v-if="match.home_icon" class="detail-scoreboard__logo" :src="match.home_icon" mode="aspectFit" />
|
||||
<view v-else class="detail-scoreboard__logo detail-scoreboard__logo--placeholder">
|
||||
<text>{{ (match.home_team || '?').slice(0, 1) }}</text>
|
||||
</view>
|
||||
<text class="detail-scoreboard__name">{{ match.home_team || '主队' }}</text>
|
||||
</view>
|
||||
<view class="detail-scoreboard__center">
|
||||
<view v-if="match.status !== 0" class="detail-scoreboard__score">
|
||||
<text>{{ match.home_score ?? 0 }}</text>
|
||||
<text class="detail-scoreboard__colon">:</text>
|
||||
<text>{{ match.away_score ?? 0 }}</text>
|
||||
</view>
|
||||
<text v-else class="detail-scoreboard__start-time">{{ formatMatchTime(match.match_time) || '未开始' }}</text>
|
||||
<text class="detail-scoreboard__meta">{{ match.league_name || '赛事' }}{{ match.round_name ? ` · ${match.round_name}` : '' }}</text>
|
||||
<text v-if="match.status === 1 && match.current_minute" class="detail-scoreboard__live-minute">{{ match.current_minute }}</text>
|
||||
</view>
|
||||
<view class="detail-scoreboard__team">
|
||||
<image v-if="match.away_icon" class="detail-scoreboard__logo" :src="match.away_icon" mode="aspectFit" />
|
||||
<view v-else class="detail-scoreboard__logo detail-scoreboard__logo--placeholder">
|
||||
<text>{{ (match.away_team || '?').slice(0, 1) }}</text>
|
||||
</view>
|
||||
<text class="detail-scoreboard__name">{{ match.away_team || '客队' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-x class="detail-tabs" show-scrollbar="false">
|
||||
<view class="detail-tabs__inner">
|
||||
<view v-for="tab in tabList" :key="tab.key" class="detail-tabs__item"
|
||||
@@ -242,7 +225,7 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- ====== 赔率 ====== -->
|
||||
<!-- ====== 盘口 ====== -->
|
||||
<scroll-view v-if="activeTab === 'odds'" scroll-y class="detail-tab-scroll">
|
||||
<MatchOddsDetail :home-team="match.home_team" :away-team="match.away_team"
|
||||
:match-time="match.match_time" :competition-id="match.competition_id"
|
||||
@@ -524,9 +507,14 @@ const liveTextList = ref<any[]>([])
|
||||
const activeLiveLineKey = ref('')
|
||||
const activeLiveError = ref('')
|
||||
const livePlayerLoading = ref(false)
|
||||
const showLiveLineSelector = ref(false)
|
||||
let liveTextTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const matchDetailTitle = computed(() => {
|
||||
const homeTeam = String(match.value?.home_team || '').trim()
|
||||
const awayTeam = String(match.value?.away_team || '').trim()
|
||||
return homeTeam && awayTeam ? `${homeTeam} vs ${awayTeam}` : '赛事详情'
|
||||
})
|
||||
|
||||
const thirdPartyLiveUrl = computed(() => String(match.value?.live_url || '').trim())
|
||||
const liveList = computed<MatchLiveLineItem[]>(() => {
|
||||
return Array.isArray(match.value?.live_list) ? match.value.live_list : []
|
||||
@@ -550,7 +538,7 @@ const liveSourceText = computed(() => {
|
||||
const tabList = [
|
||||
{ key: 'live', label: '文字直播' },
|
||||
{ key: 'data', label: '比赛数据' },
|
||||
{ key: 'odds', label: '赔率' },
|
||||
{ key: 'odds', label: '盘口' },
|
||||
{ key: 'lineup', label: '阵容' },
|
||||
{ key: 'recent', label: '近期战绩' },
|
||||
{ key: 'ai', label: 'AI分析' },
|
||||
@@ -711,7 +699,6 @@ watch(
|
||||
activeLiveLineKey.value = ''
|
||||
activeLiveError.value = ''
|
||||
livePlayerLoading.value = false
|
||||
showLiveLineSelector.value = false
|
||||
return
|
||||
}
|
||||
if (list.some((item) => item.lineKey === activeLiveLineKey.value)) return
|
||||
@@ -865,20 +852,11 @@ const selectLiveLine = (item: MatchDetailLiveLine) => {
|
||||
activeLiveLineKey.value = item.lineKey
|
||||
activeLiveError.value = ''
|
||||
livePlayerLoading.value = !!item.playUrl
|
||||
showLiveLineSelector.value = false
|
||||
if (item.playUrl) {
|
||||
triggerLiveAutoPlay()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLiveLineSelector = () => {
|
||||
if (!resolvedLiveList.value.length) {
|
||||
uni.showToast({ title: '暂无直播线路', icon: 'none' })
|
||||
return
|
||||
}
|
||||
showLiveLineSelector.value = !showLiveLineSelector.value
|
||||
}
|
||||
|
||||
const openActiveLiveSource = () => {
|
||||
navigateToLiveWebview(activeLiveSourceUrl.value)
|
||||
}
|
||||
@@ -1059,140 +1037,99 @@ onUnload(() => {
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.match-info-wrap {
|
||||
margin: 24rpx;
|
||||
}
|
||||
|
||||
.upcoming-card {
|
||||
.detail-scoreboard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
|
||||
&__header {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__league {
|
||||
font-size: 24rpx;
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
font-size: 22rpx;
|
||||
|
||||
&--blue {
|
||||
background: #e5edff;
|
||||
|
||||
text {
|
||||
color: #185dff;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__hot {
|
||||
width: 44rpx;
|
||||
height: 42rpx;
|
||||
border-radius: 10rpx;
|
||||
background: #d84293;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__teams {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
min-height: 270rpx;
|
||||
padding: 28rpx 38rpx 30rpx;
|
||||
border-bottom: 1rpx solid #eef0f5;
|
||||
|
||||
&__team {
|
||||
flex: 1;
|
||||
width: 190rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
gap: 14rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
&__logo {
|
||||
width: 92rpx;
|
||||
height: 92rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--placeholder {
|
||||
border-radius: 50%;
|
||||
background: #eef3ff;
|
||||
color: #1468f5;
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
width: 100%;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #121826;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
|
||||
&__score {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
&__vs {
|
||||
font-size: 24rpx;
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
height: 2rpx;
|
||||
background: #f2f2f2;
|
||||
margin: 16rpx 0;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
&__score {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 24rpx;
|
||||
font-size: 68rpx;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #090d18;
|
||||
}
|
||||
|
||||
&__colon {
|
||||
font-size: 54rpx;
|
||||
}
|
||||
|
||||
&__start-time {
|
||||
min-height: 68rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 50rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 25rpx;
|
||||
background: #e5edff;
|
||||
|
||||
text {
|
||||
font-size: 24rpx;
|
||||
color: #185dff;
|
||||
font-weight: 500;
|
||||
}
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #1468f5;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__time {
|
||||
&__meta {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.35;
|
||||
color: #7b8495;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__live-minute {
|
||||
margin-top: -6rpx;
|
||||
font-size: 24rpx;
|
||||
color: #808080;
|
||||
font-weight: 700;
|
||||
color: #12a34a;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user