Files
sbnews/uniapp/src/packages_match/components/MatchCenterTabsPanel.vue
T

1560 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="match-center-tabs">
<view class="content-tabs">
<view v-for="tab in contentTabs" :key="tab.key" class="content-tabs__item"
:class="{ 'content-tabs__item--active': activeTab === tab.key && !hotLeagueMode }"
@tap="switchContentTab(tab.key)">
<text>{{ tab.label }}</text>
</view>
<view class="content-tabs__item" :class="{ 'content-tabs__item--active': hotLeagueMode }"
@tap="openHotLeagues">
<text>热门联赛</text>
</view>
</view>
<view v-if="activeTab === 'schedule'" class="date-filter">
<view class="date-filter__previous" @tap="shiftDateWindow(-1)">
<text></text>
</view>
<scroll-view scroll-x class="date-filter__dates" show-scrollbar="false">
<view class="date-filter__dates-inner">
<view v-for="item in dateTabs" :key="item.key" class="date-filter__date"
:class="{ 'date-filter__date--active': selectedDateKey === item.key }"
@tap="selectMatchDate(item.key)">
<text class="date-filter__date-main">{{ item.label }}</text>
<text class="date-filter__date-week">{{ item.week }}</text>
</view>
</view>
</scroll-view>
<picker mode="date" :value="selectedDateKey" @change="handleDatePickerChange">
<view class="date-filter__calendar">
<u-icon name="calendar" size="36" color="#20283a"></u-icon>
</view>
</picker>
</view>
<scroll-view v-if="activeTab === 'schedule'" scroll-x class="sport-tabs">
<view class="sport-tabs__inner">
<view v-for="(tab, idx) in visibleSportTabs" :key="idx" class="sport-tab"
:class="{ 'sport-tab--active': currentSport === tab.value }" @tap="switchSport(tab.value)">
<text class="sport-tab__label">{{ tab.label }}</text>
</view>
</view>
</scroll-view>
<scroll-view v-if="activeTab === 'schedule' && leagueTabs.length > 0" scroll-x class="league-tabs">
<view class="league-tabs__inner">
<view class="league-tab" :class="{ 'league-tab--active': currentLeague === '' }"
@tap="switchLeague('')">
<text>全部</text>
</view>
<view v-for="(league, idx) in leagueTabs" :key="idx" class="league-tab"
:class="{ 'league-tab--active': currentLeague === league.league_name }"
@tap="switchLeague(league.league_name)">
<text>{{ league.league_name }}</text>
</view>
</view>
</scroll-view>
<scroll-view scroll-y class="match-content" @scrolltolower="handleScrollToLower">
<block v-if="activeTab === 'schedule'">
<view v-for="group in matchGroups" :key="group.title" class="match-group"
:class="{ 'match-group--ended': group.type === 'ended' }">
<view class="match-group__header" @tap="toggleGroup(group.type)">
<view class="match-group__dot" :class="'match-group__dot--' + group.type"></view>
<text class="match-group__title">{{ group.title }}</text>
<text class="match-group__count">{{ group.items.length }}</text>
<view class="match-group__more"
:class="{ 'match-group__more--collapsed': collapsedGroups[group.type] }">
<text>{{ collapsedGroups[group.type] ? '展开' : '查看更多' }}</text>
<text class="match-group__arrow"></text>
</view>
</view>
<view v-for="item in group.items" :key="item.id || item.match_id"
v-show="!collapsedGroups[group.type]" class="match-card-wrap" @tap="goDetail(item)">
<MatchCenterScheduleCard :match="item" @detail="goDetail(item)" @ai="goAiAnalysis(item)" />
</view>
</view>
<view v-if="scheduleLoading" class="content-loading">
<u-loading mode="circle" />
<text>加载中...</text>
</view>
<view v-if="!scheduleLoading && !matchGroups.length" class="content-empty">
<u-empty :text="hotLeagueMode && !leagueTabs.length ? '暂无热门联赛' : '暂无赛事'" mode="data" />
</view>
<view v-if="endedNoMore && endedList.length" class="content-nomore">
<text>没有更多赛事了</text>
</view>
</block>
<block v-else-if="activeTab === 'live'">
<view v-if="liveLoading" class="content-loading">
<u-loading mode="circle" />
<text>加载中...</text>
</view>
<view v-else-if="liveCards.length" class="match-live-grid">
<view v-for="live in liveCards" :key="`live-${live.live_id}`" class="match-live-card"
@tap="openLiveCard(live)">
<view class="match-live-card__player-wrap"
:class="{ 'match-live-card__player-wrap--poster': useLiveCardPosterMode }">
<video v-if="!useLiveCardPosterMode" :id="buildLiveVideoId(live)"
class="match-live-card__player" :src="resolveLivePlayUrl(live)"
:poster="resolveLivePoster(live)" :autoplay="true" :muted="true"
:controls="!hideLiveCardControlsOnAndroid"
:show-play-btn="!hideLiveCardControlsOnAndroid"
:show-fullscreen-btn="!hideLiveCardControlsOnAndroid" object-fit="cover" />
<view v-else class="match-live-card__poster">
<image v-if="resolveLivePoster(live)" class="match-live-card__poster-image"
:src="resolveLivePoster(live)" mode="aspectFill" />
<view v-else class="match-live-card__poster-empty">
<text>{{ livePosterTitle(live) }}</text>
</view>
<view class="match-live-card__poster-shade"></view>
<view class="match-live-card__play">
<view class="match-live-card__play-icon"></view>
</view>
<text class="match-live-card__status">{{ liveStatusText(live) }}</text>
</view>
</view>
<view class="match-live-card__teams">
<text class="match-live-card__line">{{ liveLineTitle(live) }}</text>
</view>
<view class="match-live-card__meta">
<view class="match-live-card__anchor">
<image v-if="liveAnchorAvatar(live)" class="match-live-card__avatar"
:src="liveAnchorAvatar(live)" mode="aspectFill" />
<view v-else class="match-live-card__avatar match-live-card__avatar--empty">
<text></text>
</view>
<text class="match-live-card__anchor-name">{{ liveAnchorName(live) }}</text>
</view>
<view class="match-live-card__stats">
<text class="match-live-card__stat">观看 {{ formatLiveCount(live.room_view_count)
}}</text>
<text class="match-live-card__stat">关注 {{ formatLiveCount(live.room_focus_count)
}}</text>
</view>
<text class="match-live-card__notice">{{ liveRoomNotice(live) }}</text>
</view>
</view>
</view>
<view v-else class="content-empty">
<u-empty text="暂无直播" mode="data" />
</view>
</block>
<block v-else>
<view class="match-odds-filter">
<scroll-view scroll-x class="match-odds-filter__tabs match-odds-filter__platforms"
show-scrollbar="false">
<view class="match-odds-filter__tabs-inner">
<view v-for="item in oddsPlatformOptions" :key="item.key" class="match-odds-filter__tab"
:class="{ 'match-odds-filter__tab--active': oddsSourceSite === item.key }"
@tap="switchOddsSourceSite(item.key)">
<text>{{ item.label }}</text>
</view>
</view>
</scroll-view>
<!-- 暂不展示盘口类型筛选保留状态和接口参数供后续恢复 -->
<!-- <scroll-view scroll-x class="match-odds-filter__tabs" show-scrollbar="false">
<view class="match-odds-filter__tabs-inner">
<view v-for="item in oddsShowTypeOptions" :key="item.key" class="match-odds-filter__tab"
:class="{ 'match-odds-filter__tab--active': oddsShowType === item.key }"
@tap="switchOddsShowType(item.key)">
<text>{{ item.label }}</text>
</view>
</view>
</scroll-view> -->
<view class="match-odds-filter__search">
<u-icon name="search" size="30" color="#94a3b8"></u-icon>
<input v-model="oddsSearchText" class="match-odds-filter__input" confirm-type="search"
placeholder="搜索联赛/球队" placeholder-class="match-odds-filter__placeholder"
@confirm="confirmOddsSearch" />
<view v-if="oddsSearchText || oddsKeyword" class="match-odds-filter__clear"
@tap="clearOddsKeyword">
<u-icon name="close" size="24" color="#94a3b8"></u-icon>
</view>
<view class="match-odds-filter__button" @tap="confirmOddsSearch">
<text>筛选</text>
</view>
</view>
<view class="match-odds-filter__meta">
<text>{{ oddsFilterSummary }}</text>
</view>
</view>
<view v-if="oddsLoading" class="content-loading">
<u-loading mode="circle" />
<text>加载中...</text>
</view>
<block v-else-if="oddsList.length">
<view v-for="match in oddsList" :key="oddsMatchKey(match)" class="match-odds-card"
@tap="goOddsMatchDetail(match)">
<view class="match-odds-card__head">
<text class="match-odds-card__league">{{ match.league_name || '赛事赔率' }}</text>
<text class="match-odds-card__time">{{ oddsTimeText(match) }}</text>
</view>
<view class="match-odds-card__teams">
<view class="match-odds-card__team">
<image v-if="match.home_icon" class="match-odds-card__logo" :src="match.home_icon"
mode="aspectFit" />
<text class="match-odds-card__team-name">{{ match.home_team }}</text>
</view>
<view class="match-odds-card__vs">
<text>{{ oddsCenterText(match) }}</text>
</view>
<view class="match-odds-card__team match-odds-card__team--right">
<image v-if="match.away_icon" class="match-odds-card__logo" :src="match.away_icon"
mode="aspectFit" />
<text class="match-odds-card__team-name">{{ match.away_team }}</text>
</view>
</view>
<view v-for="platform in match.platforms"
:key="`${oddsMatchKey(match)}-${platform.platform_key}-${platform.source_match_id}`"
class="match-odds-platform">
<view class="match-odds-platform__head">
<view class="match-odds-platform__name">
<text>{{ platform.platform_name }}</text>
<text class="match-odds-platform__tag">{{ platform.show_type_text }}</text>
</view>
<text class="match-odds-platform__update">{{ formatOddsUpdateTime(platform.update_time)
}}</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>
</view>
</view>
</block>
<view v-else class="content-empty">
<u-empty text="暂无赔率" mode="data" />
</view>
</block>
<view class="match-content__bottom-space" />
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import MatchCenterScheduleCard from './MatchCenterScheduleCard.vue'
import { getLocalMatchDate } from '@/utils/match-time'
import {
getMatchLeagues,
getMatchList,
getMatchLiveCards,
getMatchOdds,
getSportTypes,
type MatchLiveCardItem,
type MatchOddsMatchItem,
} from '@/api/match'
type MatchCenterTab = 'schedule' | 'live' | 'odds'
type OddsShowType = '' | 'live' | 'today' | 'early'
type OddsPlatformOption = { key: string; label: string }
const showStandaloneOddsTab = false
const allContentTabs: { key: MatchCenterTab; label: string }[] = [
{ key: 'schedule', label: '赛程' },
{ key: 'live', label: '直播' },
{ key: 'odds', label: '赔率' },
]
const contentTabs = allContentTabs.filter((item) => showStandaloneOddsTab || item.key !== 'odds')
const oddsShowTypeOptions: { key: OddsShowType; label: string }[] = [
{ key: '', label: '全部盘口' },
{ key: 'live', label: '滚球盘' },
{ key: 'today', label: '今日盘' },
{ key: 'early', label: '早盘' },
]
const defaultOddsPlatformOptions: OddsPlatformOption[] = [
{ key: '', label: '全部平台' },
{ key: 'hg', label: '皇冠' },
{ key: 'titan007', label: '球探' },
]
const activeTab = ref<MatchCenterTab>('schedule')
const hotLeagueMode = ref(false)
const sportTabs = ref<any[]>([])
const currentSport = ref(0)
const currentLeague = ref('')
const allLeagues = ref<Record<number, any[]>>({})
const selectedDateKey = ref('')
const dateWindowAnchorKey = ref('')
const liveList = ref<any[]>([])
const upcomingList = ref<any[]>([])
const endedList = ref<any[]>([])
const endedPage = ref(1)
const endedPageSize = 20
const endedNoMore = ref(false)
const scheduleLoading = ref(false)
const liveCards = ref<MatchLiveCardItem[]>([])
const liveLoading = ref(false)
const liveLoaded = ref(false)
const hideLiveCardControlsOnAndroid = ref(false)
const useLiveCardPosterMode = ref(false)
const oddsList = ref<MatchOddsMatchItem[]>([])
const oddsLoading = ref(false)
const oddsLoaded = ref(false)
const oddsPlatformOptions = ref<OddsPlatformOption[]>([...defaultOddsPlatformOptions])
const oddsSourceSite = ref('')
const oddsShowType = ref<OddsShowType>('')
const oddsKeyword = ref('')
const oddsSearchText = ref('')
const oddsRequestSeq = ref(0)
const collapsedGroups = ref<Record<string, boolean>>({
live: false,
upcoming: false,
postponed: false,
ended: false,
other: false,
})
const formatDateKey = (date: Date) => {
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}`
}
const parseDateKey = (key: string) => {
const [year, month, day] = String(key || '').split('-').map(Number)
if (!year || !month || !day) return new Date()
return new Date(year, month - 1, day)
}
const addDateDays = (key: string, days: number) => {
const date = parseDateKey(key)
date.setDate(date.getDate() + days)
return formatDateKey(date)
}
const getMatchDateKey = (match: any) => {
const date = getLocalMatchDate(match?.match_time, match)
return date ? formatDateKey(date) : ''
}
const dateTabs = computed(() => {
const anchorKey = dateWindowAnchorKey.value || selectedDateKey.value || formatDateKey(new Date())
return [-3, -2, -1, 0, 1, 2, 3].map((offset) => {
const key = addDateDays(anchorKey, offset)
const date = parseDateKey(key)
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][date.getDay()]
return {
key,
label: `${date.getMonth() + 1}/${date.getDate()}`,
week,
}
})
})
const selectMatchDate = (key: string) => {
selectedDateKey.value = key
}
const shiftDateWindow = (days: number) => {
const anchorKey = dateWindowAnchorKey.value || selectedDateKey.value || formatDateKey(new Date())
const nextKey = addDateDays(anchorKey, days)
dateWindowAnchorKey.value = nextKey
selectedDateKey.value = nextKey
}
const handleDatePickerChange = (event: any) => {
const key = String(event?.detail?.value || '')
if (!key) return
dateWindowAnchorKey.value = key
selectedDateKey.value = key
}
const ensureSelectedMatchDate = () => {
const allMatches = [...liveList.value, ...upcomingList.value, ...endedList.value]
const availableKeys = new Set(allMatches.map(getMatchDateKey).filter(Boolean))
if (selectedDateKey.value && availableKeys.has(selectedDateKey.value)) return
const todayKey = formatDateKey(new Date())
const preferredMatch = liveList.value[0] || upcomingList.value[0] || endedList.value[0]
const preferredKey = availableKeys.has(todayKey) ? todayKey : getMatchDateKey(preferredMatch)
selectedDateKey.value = preferredKey || todayKey
dateWindowAnchorKey.value = selectedDateKey.value
}
const filterScheduleItems = (items: any[]) => {
return selectedDateKey.value
? items.filter((item: any) => getMatchDateKey(item) === selectedDateKey.value)
: items
}
const leagueTabs = computed(() => {
let leagues: any[] = []
if (currentSport.value === 0) {
Object.keys(allLeagues.value || {}).forEach((key) => {
const items = (allLeagues.value as any)[key]
if (Array.isArray(items)) leagues.push(...items)
})
} else {
leagues = allLeagues.value[currentSport.value] || []
}
if (hotLeagueMode.value) {
leagues = leagues.filter((item: any) => Number(item?.is_hot) === 1)
}
return leagues
})
const visibleSportTabs = computed(() => {
if (!hotLeagueMode.value) return sportTabs.value
const hotSportTypes = new Set<number>()
Object.keys(allLeagues.value).forEach((sportType) => {
const leagues = allLeagues.value[Number(sportType)] || []
if (leagues.some((league: any) => Number(league?.is_hot) === 1)) {
hotSportTypes.add(Number(sportType))
}
})
return sportTabs.value.filter((tab: any) => Number(tab?.value) === 0 || hotSportTypes.has(Number(tab?.value)))
})
const matchGroups = computed(() => {
const groups: { title: string; type: string; items: any[]; order: number }[] = []
const visibleLiveList = filterScheduleItems(liveList.value)
const visibleUpcomingList = filterScheduleItems(upcomingList.value)
const visibleEndedList = filterScheduleItems(endedList.value)
if (visibleLiveList.length > 0) {
groups.push({ title: '进行中', type: 'live', items: visibleLiveList, order: 0 })
}
if (visibleUpcomingList.length > 0) {
groups.push({ title: '即将开始', type: 'upcoming', items: visibleUpcomingList, order: 1 })
}
if (visibleEndedList.length > 0) {
groups.push({ title: '已结束', type: 'ended', items: visibleEndedList, order: 2 })
}
return groups
})
const oddsFilterSummary = computed(() => {
const platformLabel = oddsPlatformOptions.value.find(
(item) => item.key === oddsSourceSite.value,
)?.label || '全部平台'
const showTypeLabel = oddsShowTypeOptions.find((item) => item.key === oddsShowType.value)?.label || '全部盘口'
const keyword = oddsKeyword.value.trim()
const parts = [platformLabel, showTypeLabel]
if (keyword) parts.push(`"${keyword}"`)
parts.push(`${oddsList.value.length}场`)
return parts.join(' · ')
})
const initLayout = () => {
const info = uni.getSystemInfoSync()
const isAndroidPlatform = String(info.platform || '').toLowerCase() === 'android'
hideLiveCardControlsOnAndroid.value = isAndroidPlatform
useLiveCardPosterMode.value = false
// #ifdef APP-PLUS
useLiveCardPosterMode.value = isAndroidPlatform
// #endif
}
const fetchSportTypes = async () => {
try {
const types = await getSportTypes() || []
sportTabs.value = [
{ value: 0, label: '全部' },
...types.filter((item: any) => Number(item?.value || 0) > 0),
]
} catch (error) {
console.error('获取运动类型失败', error)
}
}
const fetchLeagues = async () => {
try {
allLeagues.value = await getMatchLeagues() || {}
} catch (error) {
console.error('获取联赛列表失败', error)
}
}
const fetchMatchList = async (refresh = false) => {
if (scheduleLoading.value) return
if (refresh) {
endedPage.value = 1
endedNoMore.value = false
liveList.value = []
upcomingList.value = []
endedList.value = []
}
scheduleLoading.value = true
try {
const params: Record<string, any> = {
ended_page: endedPage.value,
ended_page_size: endedPageSize,
}
if (currentSport.value > 0) params.sport_type = currentSport.value
if (currentLeague.value) params.league_name = currentLeague.value
if (hotLeagueMode.value && !currentLeague.value) params.hot_only = 1
if (!refresh) params.ended_only = 1
const data = await getMatchList(params)
if (refresh) {
liveList.value = data?.live || []
upcomingList.value = data?.upcoming || []
}
const newEnded = data?.ended?.lists || []
endedList.value = refresh ? newEnded : endedList.value.concat(newEnded)
ensureSelectedMatchDate()
if (newEnded.length < endedPageSize) endedNoMore.value = true
} catch (error) {
console.error('获取赛事失败', error)
} finally {
scheduleLoading.value = false
}
}
const fetchLiveCards = async (force = false) => {
if (!force && (liveLoaded.value || liveLoading.value)) return
liveLoading.value = true
try {
const data = await getMatchLiveCards()
liveCards.value = Array.isArray(data?.list) ? data.list : []
liveLoaded.value = true
} catch (error) {
console.error('获取直播失败', error)
liveCards.value = []
} finally {
liveLoading.value = false
}
}
const buildOddsRequestParams = () => {
const params: {
sport_type?: number
league_name?: string
show_type?: 'live' | 'today' | 'early'
source_site?: string
keyword?: string
include_special: number
limit: number
} = {
include_special: 0,
limit: 300,
}
if (currentSport.value > 0) params.sport_type = currentSport.value
if (currentLeague.value) params.league_name = currentLeague.value
if (oddsShowType.value) params.show_type = oddsShowType.value
if (oddsSourceSite.value) params.source_site = oddsSourceSite.value
const keyword = oddsKeyword.value.trim()
if (keyword) params.keyword = keyword
return params
}
const fetchOdds = async (force = false) => {
if (!force && (oddsLoaded.value || oddsLoading.value)) return
const requestSeq = oddsRequestSeq.value + 1
oddsRequestSeq.value = requestSeq
oddsLoading.value = true
try {
const data = await getMatchOdds(buildOddsRequestParams())
if (requestSeq === oddsRequestSeq.value) {
oddsList.value = Array.isArray(data?.list) ? data.list : []
const platformOptions = Array.isArray(data?.platform_options)
? data.platform_options
: []
const platformMap = new Map(
defaultOddsPlatformOptions.map((item) => [item.key, item]),
)
platformOptions.forEach((item: any) => {
if (item?.key) {
const key = String(item.key)
platformMap.set(key, {
key,
label: key === 'hg' ? '皇冠' : String(item.label || item.key).trim(),
})
}
})
oddsPlatformOptions.value = Array.from(platformMap.values())
oddsLoaded.value = true
}
} catch (error) {
console.error('获取赔率失败', error)
if (requestSeq === oddsRequestSeq.value) oddsList.value = []
} finally {
if (requestSeq === oddsRequestSeq.value) oddsLoading.value = false
}
}
const refreshCurrentTab = () => {
if (activeTab.value === 'schedule') {
void fetchMatchList(true)
} else if (activeTab.value === 'live') {
liveLoaded.value = false
void fetchLiveCards(true)
} else {
oddsLoaded.value = false
void fetchOdds(true)
}
}
const switchContentTab = (tab: MatchCenterTab) => {
if (activeTab.value === tab && !hotLeagueMode.value) return
hotLeagueMode.value = false
activeTab.value = tab
if (tab === 'schedule') void fetchMatchList(true)
if (tab === 'live') void fetchLiveCards()
if (tab === 'odds') void fetchOdds()
}
const openHotLeagues = () => {
if (hotLeagueMode.value) return
hotLeagueMode.value = true
activeTab.value = 'schedule'
currentSport.value = 0
currentLeague.value = ''
void fetchMatchList(true)
}
const switchSport = (value: number) => {
if (currentSport.value === value) return
currentSport.value = value
currentLeague.value = ''
liveLoaded.value = false
oddsLoaded.value = false
refreshCurrentTab()
}
const switchLeague = (name: string) => {
if (currentLeague.value === name) return
currentLeague.value = name
liveLoaded.value = false
oddsLoaded.value = false
refreshCurrentTab()
}
const toggleGroup = (type: string) => {
collapsedGroups.value[type] = !collapsedGroups.value[type]
}
const handleScrollToLower = () => {
if (activeTab.value !== 'schedule' || endedNoMore.value || scheduleLoading.value) return
endedPage.value++
void fetchMatchList()
}
const goDetail = (item: any) => {
const id = item?.id || item?.match_id
if (!id) return
uni.navigateTo({ url: `/pages/match_detail/match_detail?id=${id}` })
}
const goAiAnalysis = (item: any) => {
const id = item?.id || item?.match_id
if (!id) return
uni.navigateTo({ url: `/pages/ai_analysis/ai_analysis?type=match&id=${id}` })
}
const goOddsMatchDetail = (match: MatchOddsMatchItem) => {
if (!match?.id) return
goDetail(match)
}
const switchOddsShowType = (showType: OddsShowType) => {
if (oddsShowType.value === showType) return
oddsShowType.value = showType
oddsLoaded.value = false
void fetchOdds(true)
}
const switchOddsSourceSite = (sourceSite: string) => {
if (oddsSourceSite.value === sourceSite) return
oddsSourceSite.value = sourceSite
oddsLoaded.value = false
void fetchOdds(true)
}
const confirmOddsSearch = () => {
const keyword = oddsSearchText.value.trim()
if (oddsKeyword.value === keyword && oddsLoaded.value) return
oddsKeyword.value = keyword
oddsLoaded.value = false
void fetchOdds(true)
}
const clearOddsKeyword = () => {
if (!oddsSearchText.value && !oddsKeyword.value) return
oddsSearchText.value = ''
oddsKeyword.value = ''
oddsLoaded.value = false
void fetchOdds(true)
}
const normalizeLiveText = (value: unknown) => String(value || '').trim()
const liveLineTitle = (live: MatchLiveCardItem) => {
const teams = [normalizeLiveText(live.home_team), normalizeLiveText(live.away_team)].filter(Boolean)
return teams.length ? teams.join(' VS ') : normalizeLiveText(live.title) || '直播线路'
}
const liveAnchorAvatar = (live: MatchLiveCardItem) => normalizeLiveText(live.anchor_avatar)
const liveAnchorName = (live: MatchLiveCardItem) => normalizeLiveText(live.anchor_name) || '主播'
const liveRoomNotice = (live: MatchLiveCardItem) => normalizeLiveText(live.room_notice) || '暂无公告'
const resolveLivePoster = (live: MatchLiveCardItem) => {
return liveAnchorAvatar(live) || normalizeLiveText(live.home_icon) || normalizeLiveText(live.away_icon)
}
const livePosterTitle = (live: MatchLiveCardItem) => liveLineTitle(live)
const formatLiveCount = (value: unknown) => {
const count = Number(value || 0)
if (!Number.isFinite(count) || count <= 0) return '0'
if (count >= 10000) {
return `${(count / 10000).toFixed(count >= 100000 ? 0 : 1).replace(/\.0$/, '')}万`
}
return String(Math.floor(count))
}
const buildLiveVideoId = (live: MatchLiveCardItem) => `match-live-${live.live_id || live.id || 0}`
const resolveLivePlayUrl = (live: MatchLiveCardItem) => {
const urls: string[] = [live.play_url, live.default_play_url].map(normalizeLiveText).filter(Boolean)
if (Array.isArray(live.stream_options)) {
live.stream_options.forEach((option) => {
urls.push(
normalizeLiveText(option?.url),
normalizeLiveText(option?.play_url),
normalizeLiveText(option?.play_url_m3u8),
normalizeLiveText(option?.play_url_hd_m3u8),
normalizeLiveText(option?.play_url_flv),
normalizeLiveText(option?.play_url_hd_flv),
)
})
}
return urls.find((url) => /\.(m3u8|mp4|flv)(\?|$)/i.test(url)) || urls[0] || ''
}
const resolveLiveSourceUrl = (live: MatchLiveCardItem) => {
return normalizeLiveText(live.source_url) || resolveLivePlayUrl(live)
}
const liveStatusText = (live: MatchLiveCardItem) => {
if ((live.status ?? 0) === 1) return normalizeLiveText(live.current_minute) || '进行中'
return '未开赛'
}
const openLiveCard = (live: MatchLiveCardItem) => {
const playUrl = resolveLivePlayUrl(live)
const sourceUrl = resolveLiveSourceUrl(live)
if (!playUrl && !sourceUrl) {
uni.showToast({ title: '暂无直播链接', icon: 'none' })
return
}
if (useLiveCardPosterMode.value) {
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(sourceUrl || playUrl)}` })
return
}
try {
const videoContext = uni.createVideoContext(buildLiveVideoId(live))
videoContext?.requestFullScreen?.({ direction: 90 })
videoContext?.play?.()
} catch {
uni.showToast({ title: '当前设备暂不支持全屏', icon: 'none' })
}
}
const formatMatchTime = (timestamp?: number) => {
const value = Number(timestamp || 0)
if (!value) return '--'
const date = new Date(value * 1000)
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
const oddsMatchKey = (match: MatchOddsMatchItem) => {
const sourceId = match.source_match_ids?.[0] || ''
return `${match.home_team}-${match.away_team}-${match.match_time_text || match.match_time || sourceId}`
}
const oddsCenterText = (match: MatchOddsMatchItem) => {
if (match.status === 1) return match.current_minute || '进行中'
if (match.status === 2) return `${match.home_score || 0}-${match.away_score || 0}`
return 'VS'
}
const oddsTimeText = (match: MatchOddsMatchItem) => {
return match.match_time ? formatMatchTime(match.match_time) : match.match_time_text || '--'
}
const formatOddsUpdateTime = (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}`
}
onMounted(() => {
initLayout()
void fetchSportTypes()
void fetchLeagues()
void fetchMatchList(true)
})
</script>
<style lang="scss" scoped>
.match-center-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #f7f8fb;
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
}
.content-tabs {
margin: 0 24rpx 10rpx;
padding: 6rpx;
display: flex;
gap: 6rpx;
border: 1rpx solid #dfe3eb;
border-radius: 18rpx;
background: #fff;
&__item {
flex: 1;
min-width: 0;
height: 56rpx;
border-radius: 14rpx;
color: #404654;
font-size: 24rpx;
font-weight: 700;
line-height: 56rpx;
text-align: center;
}
&__item--active {
background: linear-gradient(110deg, #1875ff 0%, #0863f3 100%);
color: #fff;
box-shadow: 0 6rpx 14rpx rgba(20, 105, 246, 0.16);
}
}
.date-filter {
height: 76rpx;
padding: 0 24rpx;
display: flex;
align-items: center;
gap: 8rpx;
background: #fff;
&__previous,
&__calendar {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__previous {
color: #20283a;
font-size: 48rpx;
font-weight: 300;
line-height: 1;
}
&__calendar {
border: 1rpx solid #e0e4eb;
border-radius: 12rpx;
background: #fff;
}
&__dates {
flex: 1;
min-width: 0;
height: 76rpx;
overflow: hidden;
white-space: nowrap;
}
&__dates-inner {
display: inline-flex;
align-items: center;
gap: 8rpx;
height: 76rpx;
min-width: 100%;
padding: 0 2rpx;
box-sizing: border-box;
}
&__date {
position: relative;
width: 112rpx;
flex: 0 0 112rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 5rpx;
border-radius: 12rpx;
color: #5b606b;
&::after {
content: '';
position: absolute;
left: 50%;
bottom: -7rpx;
width: 28rpx;
height: 4rpx;
border-radius: 999rpx;
background: transparent;
transform: translateX(-50%);
}
&--active {
background: linear-gradient(110deg, #1875ff 0%, #0863f3 100%);
color: #fff;
&::after {
background: #156df6;
}
}
}
&__date-main {
font-size: 25rpx;
font-weight: 700;
}
&__date-week {
font-size: 19rpx;
}
}
.sport-tabs {
white-space: nowrap;
height: 76rpx;
background: #fff;
}
.sport-tabs__inner {
display: inline-flex;
align-items: center;
height: 76rpx;
padding: 0 24rpx;
gap: 16rpx;
box-sizing: border-box;
}
.sport-tab {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 96rpx;
height: 50rpx;
padding: 0 14rpx;
border-radius: 12rpx;
background: #f5f6f9;
white-space: nowrap;
flex-shrink: 0;
&__label {
font-size: 24rpx;
color: #313745;
font-weight: 600;
line-height: 1.4;
}
&--active {
background: linear-gradient(110deg, #1875ff 0%, #0863f3 100%);
box-shadow: 0 8rpx 18rpx rgba(20, 105, 246, 0.17);
.sport-tab__label {
color: #fff;
font-weight: 700;
}
}
}
.league-tabs {
white-space: nowrap;
background: #fff;
border-bottom: 1rpx solid #eceef3;
}
.league-tabs__inner {
display: inline-flex;
align-items: center;
padding: 0 24rpx 14rpx;
gap: 16rpx;
white-space: nowrap;
}
.league-tab {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 96rpx;
height: 50rpx;
padding: 0 14rpx;
border: 1rpx solid transparent;
border-radius: 12rpx;
font-size: 23rpx;
color: #3f4552;
background: #f5f6f9;
white-space: nowrap;
flex-shrink: 0;
box-sizing: border-box;
&--active {
border-color: #1470f8;
background: #fff;
color: #1470f8;
font-weight: 700;
}
}
.match-content {
flex: 1;
height: 0;
min-height: 0;
background: #f7f8fb;
}
.match-group {
padding-top: 8rpx;
&__header {
display: flex;
align-items: center;
padding: 10rpx 24rpx;
gap: 8rpx;
background: #f7f8fb;
}
&__dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
&--live {
background: #10b981;
}
&--upcoming {
background: #185dff;
}
&--ended {
background: #999;
}
}
&__title {
font-size: 28rpx;
font-weight: 800;
color: #222735;
}
&__count {
font-size: 21rpx;
color: #7f8592;
}
&__more {
margin-left: auto;
display: flex;
align-items: center;
gap: 6rpx;
color: #747b88;
font-size: 21rpx;
}
&__arrow {
color: #737b89;
font-size: 34rpx;
font-weight: 300;
line-height: 1;
transition: transform 0.25s ease;
}
&__more--collapsed &__arrow {
transform: rotate(-90deg);
}
}
.match-card-wrap {
position: relative;
margin: 0 24rpx 14rpx;
}
.match-content__bottom-space {
height: calc(100rpx + env(safe-area-inset-bottom));
}
.content-loading,
.content-empty,
.content-nomore {
padding: 50rpx 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
color: #8b93a7;
font-size: 22rpx;
}
.content-loading {
flex-direction: column;
}
.content-nomore {
padding: 30rpx 0;
}
.match-live-grid {
display: flex;
flex-wrap: wrap;
padding: 12rpx 12rpx 0;
}
.match-live-card {
display: flex;
flex-direction: column;
gap: 12rpx;
width: calc(50% - 12rpx);
min-width: 0;
box-sizing: border-box;
margin: 0 6rpx 12rpx;
padding: 16rpx;
border-radius: 22rpx;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.08);
}
.match-live-card__player-wrap {
position: relative;
width: 100%;
height: 210rpx;
border-radius: 18rpx;
overflow: hidden;
background: #0f172a;
}
.match-live-card__player-wrap--poster {
background: linear-gradient(135deg, #0f172a 0%, #1e40af 100%);
}
.match-live-card__player,
.match-live-card__poster,
.match-live-card__poster-image {
width: 100%;
height: 100%;
display: block;
}
.match-live-card__poster {
position: relative;
overflow: hidden;
}
.match-live-card__poster-empty {
width: 100%;
height: 100%;
padding: 18rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: rgba(255, 255, 255, 0.86);
font-size: 22rpx;
font-weight: 600;
}
.match-live-card__poster-shade {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.28);
}
.match-live-card__play {
position: absolute;
left: 50%;
top: 50%;
width: 58rpx;
height: 58rpx;
border-radius: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.24);
border: 2rpx solid rgba(255, 255, 255, 0.78);
}
.match-live-card__play-icon {
width: 0;
height: 0;
margin-left: 4rpx;
border-top: 12rpx solid transparent;
border-bottom: 12rpx solid transparent;
border-left: 18rpx solid #fff;
}
.match-live-card__status {
position: absolute;
right: 12rpx;
top: 12rpx;
padding: 0 12rpx;
border-radius: 999rpx;
background: rgba(15, 23, 42, 0.58);
color: #fff;
font-size: 20rpx;
line-height: 34rpx;
}
.match-live-card__teams,
.match-live-card__anchor,
.match-live-card__stats {
display: flex;
align-items: center;
min-width: 0;
}
.match-live-card__line {
min-width: 0;
font-size: 24rpx;
font-weight: 600;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.match-live-card__meta {
display: flex;
flex-direction: column;
gap: 8rpx;
min-width: 0;
color: #64748b;
font-size: 22rpx;
}
.match-live-card__anchor {
gap: 8rpx;
}
.match-live-card__avatar {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
flex-shrink: 0;
background: #e2e8f0;
}
.match-live-card__avatar--empty {
display: flex;
align-items: center;
justify-content: center;
font-size: 18rpx;
}
.match-live-card__anchor-name,
.match-live-card__notice {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.match-live-card__anchor-name {
flex: 1;
color: #334155;
font-weight: 600;
}
.match-live-card__stats {
flex-wrap: wrap;
gap: 8rpx 12rpx;
}
.match-odds-filter {
padding: 14rpx 18rpx 8rpx;
}
.match-odds-filter__tabs {
white-space: nowrap;
}
.match-odds-filter__platforms {
margin-bottom: 12rpx;
}
.match-odds-filter__tabs-inner {
display: inline-flex;
align-items: center;
gap: 10rpx;
}
.match-odds-filter__tab {
height: 56rpx;
padding: 0 22rpx;
border-radius: 14rpx;
background: #f1f5f9;
color: #475569;
font-size: 24rpx;
font-weight: 700;
line-height: 56rpx;
}
.match-odds-filter__tab--active {
background: #1d4ed8;
color: #fff;
}
.match-odds-filter__search {
height: 72rpx;
margin-top: 14rpx;
padding: 0 10rpx 0 18rpx;
border: 1rpx solid #e2e8f0;
border-radius: 16rpx;
display: flex;
align-items: center;
gap: 10rpx;
}
.match-odds-filter__input {
flex: 1;
min-width: 0;
height: 70rpx;
color: #111827;
font-size: 26rpx;
}
.match-odds-filter__placeholder {
color: #94a3b8;
font-size: 26rpx;
}
.match-odds-filter__clear {
width: 44rpx;
height: 44rpx;
border-radius: 14rpx;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
}
.match-odds-filter__button {
width: 92rpx;
height: 52rpx;
border-radius: 14rpx;
background: #0f172a;
color: #fff;
font-size: 24rpx;
font-weight: 700;
line-height: 52rpx;
text-align: center;
}
.match-odds-filter__meta {
margin-top: 10rpx;
color: #64748b;
font-size: 22rpx;
}
.match-odds-card {
margin: 12rpx 18rpx;
padding: 18rpx 16rpx;
border-radius: 18rpx;
background: #fff;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
}
.match-odds-card__head,
.match-odds-platform__head,
.match-odds-platform__name,
.match-odds-card__teams,
.match-odds-card__team {
display: flex;
align-items: center;
min-width: 0;
}
.match-odds-card__head,
.match-odds-platform__head {
justify-content: space-between;
gap: 12rpx;
}
.match-odds-card__league {
flex: 1;
min-width: 0;
color: #64748b;
font-size: 22rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.match-odds-card__time,
.match-odds-platform__update {
color: #94a3b8;
font-size: 20rpx;
flex-shrink: 0;
}
.match-odds-card__teams {
justify-content: space-between;
gap: 12rpx;
padding: 16rpx 0 12rpx;
}
.match-odds-card__team {
flex: 1;
gap: 8rpx;
&--right {
justify-content: flex-end;
}
}
.match-odds-card__logo {
width: 42rpx;
height: 42rpx;
flex-shrink: 0;
}
.match-odds-card__team-name {
min-width: 0;
color: #111827;
font-size: 28rpx;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.match-odds-card__vs {
width: 104rpx;
flex-shrink: 0;
text-align: center;
color: #2563eb;
font-size: 28rpx;
font-weight: 800;
}
.match-odds-platform {
padding-top: 14rpx;
border-top: 1rpx solid #eef2f7;
&+& {
margin-top: 14rpx;
}
}
.match-odds-platform__name {
gap: 8rpx;
color: #0f172a;
font-size: 24rpx;
font-weight: 700;
}
.match-odds-platform__tag {
padding: 0 10rpx;
border-radius: 999rpx;
background: #eaf1ff;
color: #2563eb;
font-size: 20rpx;
line-height: 32rpx;
}
.match-odds-market {
display: grid;
grid-template-columns: 104rpx minmax(0, 1fr);
gap: 10rpx;
margin-top: 10rpx;
}
.match-odds-market__title {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4rpx;
color: #475569;
font-size: 22rpx;
}
.match-odds-market__line {
color: #0f172a;
font-size: 24rpx;
font-weight: 700;
}
.match-odds-market__values {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8rpx;
}
.match-odds-value {
min-width: 0;
padding: 10rpx 8rpx;
border-radius: 12rpx;
background: #f8fafc;
text-align: center;
}
.match-odds-value__label,
.match-odds-value__number {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.match-odds-value__label {
color: #64748b;
font-size: 20rpx;
}
.match-odds-value__number {
margin-top: 4rpx;
color: #dc2626;
font-size: 26rpx;
font-weight: 800;
}
</style>