1332 lines
40 KiB
Vue
1332 lines
40 KiB
Vue
<template>
|
|
<view class="worldcup-page">
|
|
<view class="worldcup-header" :style="{ paddingTop: statusBarHeight + 'px' }">
|
|
<view class="worldcup-header__bar">
|
|
<view class="worldcup-header__back" @tap="goBack">
|
|
<u-icon name="arrow-left" size="38" color="#ffffff"></u-icon>
|
|
</view>
|
|
<view class="worldcup-header__switch" @tap="goBack">
|
|
<text>其他赛事</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="worldcup-header__hero">
|
|
<text class="worldcup-header__watermark">2</text>
|
|
<view class="worldcup-header__badge">
|
|
<text class="worldcup-header__badge-cup">🏆</text>
|
|
<text class="worldcup-header__badge-label">FIFA</text>
|
|
</view>
|
|
<text class="worldcup-header__title">2026年美加墨世界杯</text>
|
|
</view>
|
|
|
|
<scroll-view scroll-x class="worldcup-nav" show-scrollbar="false">
|
|
<view class="worldcup-nav__inner">
|
|
<view v-for="tab in tabs" :key="tab.key" class="worldcup-nav__item"
|
|
:class="{ 'worldcup-nav__item--active': activeTab === tab.key }" @tap="switchTab(tab.key)">
|
|
<text>{{ tab.label }}</text>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
|
|
<scroll-view scroll-y scroll-with-animation class="worldcup-scroll"
|
|
:scroll-into-view="activeTab === 'schedule' ? scheduleScrollIntoView : ''">
|
|
<block v-if="activeTab === 'live'">
|
|
<view v-if="liveCards.length" class="worldcup-live-grid">
|
|
<view
|
|
v-for="live in liveCards"
|
|
:key="`live-${live.live_id}`"
|
|
class="worldcup-live-card"
|
|
@tap="goMatchDetail(live)"
|
|
>
|
|
<view class="worldcup-live-card__top">
|
|
<text class="worldcup-live-card__badge">直播</text>
|
|
<text class="worldcup-live-card__count">{{ liveStatusText(live) }}</text>
|
|
</view>
|
|
|
|
<view class="worldcup-live-card__player-wrap" @tap.stop>
|
|
<video
|
|
:id="`worldcup-live-${live.live_id}`"
|
|
class="worldcup-live-card__player"
|
|
:src="live.play_url"
|
|
:poster="live.home_icon || live.away_icon || ''"
|
|
:autoplay="true"
|
|
:muted="true"
|
|
:controls="true"
|
|
:show-play-btn="true"
|
|
:show-fullscreen-btn="true"
|
|
object-fit="cover"
|
|
/>
|
|
</view>
|
|
|
|
<view class="worldcup-live-card__teams">
|
|
<text class="worldcup-live-card__team">{{ live.home_team }}</text>
|
|
<text class="worldcup-live-card__vs">VS</text>
|
|
<text class="worldcup-live-card__team">{{ live.away_team }}</text>
|
|
</view>
|
|
|
|
<view class="worldcup-live-card__meta">
|
|
<text>{{ matchMetaText(live, 0) }}</text>
|
|
<text>{{ formatMatchTime(live.match_time) }}</text>
|
|
</view>
|
|
|
|
<view class="worldcup-live-card__footer">
|
|
<text class="worldcup-live-card__line">{{ liveLineTitle(live) }}</text>
|
|
<text class="worldcup-live-card__switch">进入详情</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<view v-else class="worldcup-empty">
|
|
<u-empty text="暂无直播" mode="data" />
|
|
</view>
|
|
</block>
|
|
|
|
<block v-else-if="activeTab === 'schedule'">
|
|
<view class="worldcup-sortbar" @tap="toggleScheduleSort">
|
|
<text class="worldcup-sortbar__label">{{ scheduleSortLabel }}</text>
|
|
<text class="worldcup-sortbar__icon">↕</text>
|
|
</view>
|
|
|
|
<block v-if="scheduleGroups.length">
|
|
<view v-for="group in scheduleGroups" :key="group.key" :id="scheduleGroupAnchorId(group.key)"
|
|
class="worldcup-group">
|
|
<view class="worldcup-group__title">
|
|
<view class="worldcup-group__dot"></view>
|
|
<text>{{ group.label }}</text>
|
|
</view>
|
|
|
|
<view v-for="(match, index) in group.items" :key="match.id || match.match_id"
|
|
class="wc-match-card" @tap="goMatchDetail(match)">
|
|
<view class="wc-match-card__team wc-match-card__team--left">
|
|
<image v-if="match.home_icon" class="wc-match-card__logo" :src="match.home_icon"
|
|
mode="aspectFit" />
|
|
<text class="wc-match-card__team-name">{{ match.home_team }}</text>
|
|
</view>
|
|
|
|
<view class="wc-match-card__center">
|
|
<text class="wc-match-card__time">{{ matchCenterText(match) }}</text>
|
|
<text class="wc-match-card__meta">{{ matchMetaText(match, index) }}</text>
|
|
</view>
|
|
|
|
<view class="wc-match-card__team wc-match-card__team--right">
|
|
<image v-if="match.away_icon" class="wc-match-card__logo" :src="match.away_icon"
|
|
mode="aspectFit" />
|
|
<text class="wc-match-card__team-name">{{ match.away_team }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</block>
|
|
<view v-else class="worldcup-empty">
|
|
<u-empty text="暂无数据" mode="data" />
|
|
</view>
|
|
</block>
|
|
|
|
<view v-else-if="activeTab === 'rank'" class="worldcup-section">
|
|
<ranking-nav v-model="rankingMode" />
|
|
|
|
<view v-if="rankingMode === 'standings'">
|
|
<standings-panel :groups="standingData.groups" />
|
|
</view>
|
|
|
|
<view v-else>
|
|
<player-ranking-panel :title="currentRankingLabel" :header="currentRankingHeader"
|
|
:list="currentRankingList" />
|
|
</view>
|
|
</view>
|
|
|
|
<view v-else-if="activeTab === 'bracket'" class="worldcup-section">
|
|
<scroll-view scroll-x class="bracket-round-nav" show-scrollbar="false">
|
|
<view class="bracket-round-nav__inner">
|
|
<view v-for="tab in bracketTabs" :key="tab.key" class="bracket-round-nav__item"
|
|
:class="{ 'bracket-round-nav__item--active': activeBracketTab === tab.key }"
|
|
@tap="switchBracketTab(tab.key)">
|
|
<text>{{ tab.label }}</text>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
|
|
<view v-if="activeBracketRound || activeBracketTab === '决赛'" class="bracket-stage">
|
|
<view v-if="activeBracketRound?.matches?.length" class="bracket-stage__list">
|
|
<view v-for="(pair, pairIndex) in activeBracketPairs" :key="`pair-${pairIndex}`"
|
|
class="bracket-stage__pair">
|
|
<view class="bracket-stage__cards">
|
|
<view v-for="match in pair" :key="match.match_id" class="bracket-stage__item">
|
|
<view class="bracket-stage__card" @tap="goMatchDetail(match)">
|
|
<view class="bracket-stage__time">
|
|
<text class="bracket-stage__clock">{{ formatMatchTime(match.match_time)
|
|
}}</text>
|
|
<text class="bracket-stage__date">{{ formatBracketDate(match.match_time)
|
|
}}</text>
|
|
</view>
|
|
<view class="bracket-stage__divider"></view>
|
|
<view class="bracket-stage__teams">
|
|
<view class="bracket-stage__team">
|
|
<image v-if="resolveBracketTeamIcon(match.home_team, match.home_icon)"
|
|
class="bracket-stage__logo"
|
|
:src="resolveBracketTeamIcon(match.home_team, match.home_icon)"
|
|
mode="aspectFit" />
|
|
<text class="bracket-stage__name"
|
|
:class="{ 'bracket-stage__name--multi': isBracketTeamGroup(match.home_team) }">{{
|
|
resolveBracketTeamName(match.home_team) }}</text>
|
|
</view>
|
|
<view class="bracket-stage__team">
|
|
<image v-if="resolveBracketTeamIcon(match.away_team, match.away_icon)"
|
|
class="bracket-stage__logo"
|
|
:src="resolveBracketTeamIcon(match.away_team, match.away_icon)"
|
|
mode="aspectFit" />
|
|
<text class="bracket-stage__name"
|
|
:class="{ 'bracket-stage__name--multi': isBracketTeamGroup(match.away_team) }">{{
|
|
resolveBracketTeamName(match.away_team) }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<view v-if="activeBracketTab !== '决赛'" class="bracket-stage__linezone"></view>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-if="activeBracketTab === '决赛' && thirdPlaceRound?.matches?.length" class="bracket-extra">
|
|
<text class="bracket-extra__title">三四名决赛</text>
|
|
<view class="bracket-stage__list bracket-stage__list--final">
|
|
<view v-for="match in thirdPlaceRound.matches" :key="match.match_id"
|
|
class="bracket-stage__item">
|
|
<view class="bracket-stage__card" @tap="goMatchDetail(match)">
|
|
<view class="bracket-stage__time">
|
|
<text class="bracket-stage__clock">{{ formatMatchTime(match.match_time)
|
|
}}</text>
|
|
<text class="bracket-stage__date">{{ formatBracketDate(match.match_time)
|
|
}}</text>
|
|
</view>
|
|
<view class="bracket-stage__divider"></view>
|
|
<view class="bracket-stage__teams">
|
|
<view class="bracket-stage__team">
|
|
<image v-if="resolveBracketTeamIcon(match.home_team, match.home_icon)"
|
|
class="bracket-stage__logo"
|
|
:src="resolveBracketTeamIcon(match.home_team, match.home_icon)"
|
|
mode="aspectFit" />
|
|
<text class="bracket-stage__name"
|
|
:class="{ 'bracket-stage__name--multi': isBracketTeamGroup(match.home_team) }">{{
|
|
resolveBracketTeamName(match.home_team) }}</text>
|
|
</view>
|
|
<view class="bracket-stage__team">
|
|
<image v-if="resolveBracketTeamIcon(match.away_team, match.away_icon)"
|
|
class="bracket-stage__logo"
|
|
:src="resolveBracketTeamIcon(match.away_team, match.away_icon)"
|
|
mode="aspectFit" />
|
|
<text class="bracket-stage__name"
|
|
:class="{ 'bracket-stage__name--multi': isBracketTeamGroup(match.away_team) }">{{
|
|
resolveBracketTeamName(match.away_team) }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<view v-else class="worldcup-empty">
|
|
<u-empty text="暂无数据" mode="data" />
|
|
</view>
|
|
</view>
|
|
|
|
<view v-else class="worldcup-section">
|
|
<view v-if="newsLoading" class="worldcup-loading">
|
|
<u-loading mode="circle" />
|
|
<text>加载中...</text>
|
|
</view>
|
|
<view v-else-if="newsList.length">
|
|
<TranslatedArticleCard v-for="item in newsList" :key="item.id" :item="item" variant="worldcup"
|
|
author-fallback="Sport Era Editorial" />
|
|
</view>
|
|
<view v-else class="worldcup-empty">
|
|
<u-empty text="暂无数据" mode="data" />
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
import RankingNav from '../pages/worldcup/components/ranking/RankingNav.vue'
|
|
import StandingsPanel from '../pages/worldcup/components/ranking/StandingsPanel.vue'
|
|
import PlayerRankingPanel from '../pages/worldcup/components/ranking/PlayerRankingPanel.vue'
|
|
import TranslatedArticleCard from '@/components/translated-article-card/translated-article-card.vue'
|
|
import { getArticleCate, getArticleList } from '@/api/news'
|
|
import {
|
|
getWorldCupLiveCards,
|
|
getWorldCupRankings,
|
|
getWorldCupSchedule,
|
|
getWorldCupStandings,
|
|
type WorldCupLiveCardItem,
|
|
} from '@/api/match'
|
|
import { getLocalMatchDate } from '@/utils/match-time'
|
|
|
|
type MainTab = 'live' | 'schedule' | 'rank' | 'bracket' | 'news'
|
|
type RankingMode = 'standings' | 'goals' | 'assists'
|
|
type ScheduleSort = 'time' | 'group'
|
|
type BracketTabKey = '1/16决赛' | '1/8决赛' | '1/4决赛' | '半决赛' | '决赛'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
initialTab?: string
|
|
initialCid?: number | null
|
|
}>(), {
|
|
initialTab: 'schedule',
|
|
initialCid: null
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'back'): void
|
|
}>()
|
|
|
|
const tabs: { key: MainTab; label: string }[] = [
|
|
{ key: 'live', label: '直播' },
|
|
{ key: 'schedule', label: '比分和赛程' },
|
|
{ key: 'rank', label: '排名' },
|
|
{ key: 'bracket', label: '对阵图' },
|
|
{ key: 'news', label: '新闻' }
|
|
]
|
|
|
|
const WORLD_CUP_MATCH_CONTEXT = { competition_id: 61, league_name: '世界杯' }
|
|
|
|
const bracketTabs: { key: BracketTabKey; label: string }[] = [
|
|
{ key: '1/16决赛', label: '32强' },
|
|
{ key: '1/8决赛', label: '十六强' },
|
|
{ key: '1/4决赛', label: '四分之一决赛' },
|
|
{ key: '半决赛', label: '半决赛' },
|
|
{ key: '决赛', label: '决赛' }
|
|
]
|
|
|
|
const activeTab = ref<MainTab>('schedule')
|
|
const rankingMode = ref<RankingMode>('standings')
|
|
const scheduleSort = ref<ScheduleSort>('time')
|
|
const activeBracketTab = ref<BracketTabKey>(bracketTabs[0].key)
|
|
const scheduleScrollIntoView = ref('')
|
|
const statusBarHeight = ref(0)
|
|
const liveCards = ref<WorldCupLiveCardItem[]>([])
|
|
const liveCardsLoaded = ref(false)
|
|
|
|
const scheduleData = ref<any>({
|
|
current_round_name: '',
|
|
bracket_rounds: [],
|
|
schedule_rounds: []
|
|
})
|
|
|
|
const standingData = ref<any>({
|
|
stage_name: '',
|
|
groups: []
|
|
})
|
|
|
|
const rankingData = ref<Record<RankingMode, any[]>>({
|
|
standings: [],
|
|
goals: [],
|
|
assists: []
|
|
})
|
|
|
|
const rankingLoaded = ref<Record<RankingMode, boolean>>({
|
|
standings: false,
|
|
goals: false,
|
|
assists: false
|
|
})
|
|
|
|
const newsCategoryId = ref<number | null>(null)
|
|
const newsList = ref<any[]>([])
|
|
const newsLoading = ref(false)
|
|
const newsLoaded = ref(false)
|
|
|
|
const resolveNewsCategoryId = async (cid?: string | number | null) => {
|
|
const parsedCid = Number(cid || 0)
|
|
if (parsedCid > 0) {
|
|
newsCategoryId.value = parsedCid
|
|
return newsCategoryId.value
|
|
}
|
|
|
|
if (newsCategoryId.value) return newsCategoryId.value
|
|
|
|
const cateList = await getArticleCate()
|
|
const cate = (cateList || []).find((item: any) => {
|
|
const name = item?.name || item?.label || item?.title
|
|
return String(name) === '世界杯'
|
|
})
|
|
newsCategoryId.value = cate?.id || null
|
|
return newsCategoryId.value
|
|
}
|
|
|
|
const currentRankingHeader = computed(() => {
|
|
if (rankingMode.value === 'goals') return ['球员', '球队', '进球']
|
|
if (rankingMode.value === 'assists') return ['球员', '球队', '助攻']
|
|
return ['球员', '球队', '积分']
|
|
})
|
|
|
|
const currentRankingLabel = computed(() => {
|
|
if (rankingMode.value === 'goals') return '射手排名'
|
|
if (rankingMode.value === 'assists') return '助攻排名'
|
|
return '积分排名'
|
|
})
|
|
|
|
const currentRankingList = computed(() => {
|
|
if (rankingMode.value === 'goals') return rankingData.value.goals
|
|
if (rankingMode.value === 'assists') return rankingData.value.assists
|
|
return rankingData.value.standings
|
|
})
|
|
|
|
const groupRankTeamMap = computed(() => {
|
|
const map = new Map<string, { name: string; logo: string }>()
|
|
; (standingData.value.groups || []).forEach((group: any) => {
|
|
const groupKey = String(group?.group_name || '').match(/([A-L])/i)?.[1]?.toUpperCase()
|
|
if (!groupKey) return
|
|
|
|
; (group?.teams || []).forEach((team: any, index: number) => {
|
|
const rank = Number(team?.rank || team?.row_order || index + 1)
|
|
if (!rank) return
|
|
map.set(`${groupKey}${rank}`, {
|
|
name: team?.team_name || '',
|
|
logo: team?.team_logo || ''
|
|
})
|
|
})
|
|
})
|
|
return map
|
|
})
|
|
|
|
const canResolveBracketTeams = computed(() => {
|
|
const currentRoundName = String(scheduleData.value.current_round_name || '')
|
|
if (currentRoundName && !currentRoundName.includes('小组赛')) {
|
|
return true
|
|
}
|
|
|
|
const groups = standingData.value.groups || []
|
|
if (!groups.length) return false
|
|
|
|
return groups.every((group: any) =>
|
|
(group?.teams || []).every((team: any) => Number(team?.played || 0) >= 3)
|
|
)
|
|
})
|
|
|
|
const bracketRankTokenPattern = /^[A-L](?:[1-4])$/i
|
|
const bracketRankGroupPattern = /^[A-L](?:[1-4])(?:\/[A-L](?:[1-4]))+$/i
|
|
|
|
const resolveBracketRankEntry = (rawValue: any) => {
|
|
const value = String(rawValue || '').trim().toUpperCase()
|
|
if (!canResolveBracketTeams.value) return null
|
|
if (!value || !bracketRankTokenPattern.test(value)) return null
|
|
return groupRankTeamMap.value.get(value) || null
|
|
}
|
|
|
|
const isBracketTeamGroup = (rawValue: any) => {
|
|
return bracketRankGroupPattern.test(String(rawValue || '').trim().toUpperCase())
|
|
}
|
|
|
|
const resolveBracketTeamName = (rawValue: any) => {
|
|
const raw = String(rawValue || '').trim()
|
|
if (!raw) return ''
|
|
|
|
const exact = resolveBracketRankEntry(raw)
|
|
if (exact?.name) return exact.name
|
|
|
|
if (isBracketTeamGroup(raw)) {
|
|
return raw
|
|
.split('/')
|
|
.map((token) => resolveBracketRankEntry(token)?.name || token.trim())
|
|
.join('/')
|
|
}
|
|
|
|
return raw
|
|
}
|
|
|
|
const resolveBracketTeamIcon = (rawValue: any, fallback = '') => {
|
|
const raw = String(rawValue || '').trim()
|
|
const exact = resolveBracketRankEntry(rawValue)
|
|
if (exact?.logo) return exact.logo
|
|
if (bracketRankTokenPattern.test(raw) || isBracketTeamGroup(raw) || /^第\d+场/.test(raw)) return ''
|
|
return fallback
|
|
}
|
|
|
|
const normalizeLiveText = (value: unknown) => String(value || '').trim()
|
|
|
|
const liveLineTitle = (live: WorldCupLiveCardItem) => {
|
|
return normalizeLiveText(live.title) || '直播线路'
|
|
}
|
|
|
|
const liveStatusText = (live: WorldCupLiveCardItem) => {
|
|
if ((live.status ?? 0) === 1) {
|
|
return normalizeLiveText(live.current_minute) || '进行中'
|
|
}
|
|
return '未开赛'
|
|
}
|
|
|
|
const flattenScheduleMatches = computed(() => {
|
|
const matches: any[] = []
|
|
; (scheduleData.value.schedule_rounds || []).forEach((round: any) => {
|
|
; (round.matches || []).forEach((match: any, index: number) => {
|
|
matches.push({
|
|
...match,
|
|
round_name: round.round_name,
|
|
round_index: index + 1
|
|
})
|
|
})
|
|
})
|
|
return matches.sort((a, b) => (a.match_time || 0) - (b.match_time || 0))
|
|
})
|
|
|
|
const scheduleGroups = computed(() => {
|
|
const groups: Array<{ key: string; label: string; items: any[] }> = []
|
|
|
|
if (scheduleSort.value === 'time') {
|
|
const map = new Map<string, any[]>()
|
|
flattenScheduleMatches.value.forEach((match) => {
|
|
const key = formatDateKey(match.match_time)
|
|
if (!map.has(key)) map.set(key, [])
|
|
map.get(key)!.push(match)
|
|
})
|
|
map.forEach((items, key) => {
|
|
groups.push({
|
|
key,
|
|
label: items[0] ? formatDateLabel(items[0].match_time) : '未分组',
|
|
items
|
|
})
|
|
})
|
|
} else {
|
|
const map = new Map<string, any[]>()
|
|
flattenScheduleMatches.value.forEach((match) => {
|
|
const key = normalizeGroupName(match.stage || match.round_name)
|
|
if (!map.has(key)) map.set(key, [])
|
|
map.get(key)!.push(match)
|
|
})
|
|
map.forEach((items, key) => {
|
|
groups.push({ key, label: key, items })
|
|
})
|
|
}
|
|
|
|
return groups
|
|
})
|
|
|
|
const scheduleSortLabel = computed(() => scheduleSort.value === 'time' ? '时间排序' : '小组排序')
|
|
|
|
const activeBracketRound = computed(() => {
|
|
return (scheduleData.value.bracket_rounds || []).find((item: any) => item.round_name === activeBracketTab.value) || null
|
|
})
|
|
|
|
const thirdPlaceRound = computed(() => {
|
|
return (scheduleData.value.bracket_rounds || []).find((item: any) => item.round_name === '季军赛') || null
|
|
})
|
|
|
|
const activeBracketPairs = computed(() => {
|
|
const matches = activeBracketRound.value?.matches || []
|
|
const pairs: any[][] = []
|
|
for (let i = 0; i < matches.length; i += 2) {
|
|
pairs.push(matches.slice(i, i + 2))
|
|
}
|
|
return pairs
|
|
})
|
|
|
|
const initLayout = () => {
|
|
const info = uni.getSystemInfoSync()
|
|
// #ifdef APP-PLUS || MP
|
|
statusBarHeight.value = info.statusBarHeight || 44
|
|
// #endif
|
|
// #ifdef H5
|
|
statusBarHeight.value = 0
|
|
// #endif
|
|
}
|
|
|
|
const scheduleGroupAnchorId = (key: string) => `worldcup-schedule-group-${key}`
|
|
|
|
const fetchWorldCupLiveCards = async () => {
|
|
if (liveCardsLoaded.value) return
|
|
const data = await getWorldCupLiveCards()
|
|
liveCards.value = Array.isArray(data?.list) ? data.list : []
|
|
liveCardsLoaded.value = true
|
|
}
|
|
|
|
const fetchSchedule = async () => {
|
|
scheduleData.value = await getWorldCupSchedule() || { current_round_name: '', bracket_rounds: [], schedule_rounds: [] }
|
|
if (activeTab.value === 'schedule') {
|
|
void scrollScheduleToToday()
|
|
}
|
|
}
|
|
|
|
const fetchStandings = async () => {
|
|
standingData.value = await getWorldCupStandings() || { stage_name: '', groups: [] }
|
|
}
|
|
|
|
const fetchRanking = async (mode: RankingMode) => {
|
|
if (rankingLoaded.value[mode]) return
|
|
if (mode === 'standings') {
|
|
rankingLoaded.value.standings = true
|
|
return
|
|
}
|
|
const data = await getWorldCupRankings({ type: mode })
|
|
rankingData.value[mode] = data?.list || []
|
|
rankingLoaded.value[mode] = true
|
|
}
|
|
|
|
const fetchNews = async () => {
|
|
if (newsLoaded.value || newsLoading.value) return
|
|
newsLoading.value = true
|
|
try {
|
|
await resolveNewsCategoryId(props.initialCid)
|
|
|
|
if (newsCategoryId.value) {
|
|
const res = await getArticleList({
|
|
cid: newsCategoryId.value,
|
|
page_no: 1,
|
|
page_size: 8
|
|
})
|
|
const list = res?.lists || []
|
|
newsList.value = list
|
|
} else {
|
|
newsList.value = []
|
|
}
|
|
} finally {
|
|
newsLoaded.value = true
|
|
newsLoading.value = false
|
|
}
|
|
}
|
|
|
|
const ensureTabData = async (tab: MainTab) => {
|
|
if (tab === 'live') {
|
|
await fetchWorldCupLiveCards()
|
|
} else if (tab === 'schedule') {
|
|
await fetchSchedule()
|
|
} else if (tab === 'rank') {
|
|
if (rankingMode.value === 'standings') {
|
|
await fetchStandings()
|
|
} else {
|
|
await fetchRanking(rankingMode.value)
|
|
}
|
|
} else if (tab === 'bracket') {
|
|
await fetchSchedule()
|
|
} else if (tab === 'news') {
|
|
await fetchNews()
|
|
}
|
|
}
|
|
|
|
const switchTab = (tab: MainTab) => {
|
|
activeTab.value = tab
|
|
}
|
|
|
|
const switchBracketTab = (tab: BracketTabKey) => {
|
|
activeBracketTab.value = tab
|
|
}
|
|
|
|
const toggleScheduleSort = () => {
|
|
scheduleSort.value = scheduleSort.value === 'time' ? 'group' : 'time'
|
|
}
|
|
|
|
const goBack = () => {
|
|
emit('back')
|
|
}
|
|
|
|
const goMatchDetail = (match: any) => {
|
|
const id = match?.id || match?.match_id
|
|
if (!id) return
|
|
uni.navigateTo({ url: `/pages/match_detail/match_detail?id=${id}` })
|
|
}
|
|
|
|
const getWorldCupDisplayDate = (timestamp: number) => {
|
|
return getLocalMatchDate(timestamp, WORLD_CUP_MATCH_CONTEXT)
|
|
}
|
|
|
|
const formatDateKeyFromDate = (date: Date | null) => {
|
|
if (!date) return '0'
|
|
const y = date.getFullYear()
|
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
|
const day = String(date.getDate()).padStart(2, '0')
|
|
return `${y}-${m}-${day}`
|
|
}
|
|
|
|
const formatDateKey = (timestamp: number) => {
|
|
if (!timestamp) return '0'
|
|
return formatDateKeyFromDate(getWorldCupDisplayDate(timestamp))
|
|
}
|
|
|
|
const formatDateLabel = (timestamp: number) => {
|
|
if (!timestamp) return '未分组'
|
|
const d = getWorldCupDisplayDate(timestamp)
|
|
if (!d) return '未分组'
|
|
const weeks = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
|
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${weeks[d.getDay()]}`
|
|
}
|
|
|
|
const normalizeGroupName = (value: string) => {
|
|
if (!value) return '未分组'
|
|
if (/^[A-Z]$/.test(value)) return `${value}组`
|
|
return value
|
|
}
|
|
|
|
const formatMatchTime = (timestamp: number) => {
|
|
if (!timestamp) return '--'
|
|
const date = getWorldCupDisplayDate(timestamp)
|
|
if (!date) return '--'
|
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
|
}
|
|
|
|
const formatBracketDate = (timestamp: number) => {
|
|
if (!timestamp) return '--'
|
|
const date = getWorldCupDisplayDate(timestamp)
|
|
if (!date) return '--'
|
|
return `${date.getMonth() + 1}月${date.getDate()}日`
|
|
}
|
|
|
|
const getTodayScheduleDateKey = () => formatDateKeyFromDate(new Date())
|
|
|
|
const resolveTodayScheduleAnchorId = () => {
|
|
const todayKey = getTodayScheduleDateKey()
|
|
if (!todayKey) return ''
|
|
|
|
if (scheduleSort.value === 'time') {
|
|
const group = scheduleGroups.value.find((item) => item.key === todayKey && item.items.length)
|
|
if (group) return scheduleGroupAnchorId(group.key)
|
|
}
|
|
|
|
const group = scheduleGroups.value.find((item) =>
|
|
item.items.some((match: any) => formatDateKey(match.match_time) === todayKey)
|
|
)
|
|
if (!group) return ''
|
|
return scheduleGroupAnchorId(group.key)
|
|
}
|
|
|
|
const scrollScheduleToToday = async () => {
|
|
if (activeTab.value !== 'schedule') return
|
|
|
|
const targetId = resolveTodayScheduleAnchorId()
|
|
if (!targetId) return
|
|
|
|
scheduleScrollIntoView.value = ''
|
|
await nextTick()
|
|
scheduleScrollIntoView.value = targetId
|
|
}
|
|
|
|
const matchCenterText = (match: any) => {
|
|
if (match.status === 1) return match.current_minute || '进行中'
|
|
if (match.status === 2) return `${match.home_score}-${match.away_score}`
|
|
if (match.status === 3) return '已推迟'
|
|
return formatMatchTime(match.match_time)
|
|
}
|
|
|
|
const matchMetaText = (match: any, index: number) => {
|
|
const parts: string[] = []
|
|
if (match.stage) parts.push(normalizeGroupName(match.stage))
|
|
if (match.round_name && match.round_name !== match.stage) parts.push(match.round_name)
|
|
if (match.round_index || index >= 0) parts.push(`第${match.round_index || index + 1}场`)
|
|
return parts.join(' · ') || '世界杯'
|
|
}
|
|
|
|
const initViewState = async () => {
|
|
initLayout()
|
|
|
|
const legacyTab = String(props.initialTab || 'schedule')
|
|
await resolveNewsCategoryId(props.initialCid)
|
|
if (legacyTab === 'live') {
|
|
activeTab.value = 'live'
|
|
} else if (legacyTab === 'schedule') {
|
|
activeTab.value = 'schedule'
|
|
} else if (legacyTab === 'rank' || legacyTab === 'standings') {
|
|
activeTab.value = 'rank'
|
|
rankingMode.value = 'standings'
|
|
} else if (legacyTab === 'goals') {
|
|
activeTab.value = 'rank'
|
|
rankingMode.value = 'goals'
|
|
} else if (legacyTab === 'assists') {
|
|
activeTab.value = 'rank'
|
|
rankingMode.value = 'assists'
|
|
} else if (legacyTab === 'news') {
|
|
activeTab.value = 'news'
|
|
} else {
|
|
activeTab.value = 'bracket'
|
|
}
|
|
|
|
await Promise.all([fetchSchedule(), fetchStandings()])
|
|
|
|
if (activeTab.value === 'rank' && rankingMode.value !== 'standings') {
|
|
await fetchRanking(rankingMode.value)
|
|
}
|
|
if (activeTab.value === 'news') {
|
|
await fetchNews()
|
|
}
|
|
}
|
|
|
|
watch(activeTab, (tab) => {
|
|
ensureTabData(tab)
|
|
}, { immediate: true })
|
|
|
|
watch(rankingMode, () => {
|
|
if (activeTab.value === 'rank') {
|
|
ensureTabData('rank')
|
|
}
|
|
})
|
|
|
|
watch(scheduleSort, () => {
|
|
if (activeTab.value === 'schedule') {
|
|
void scrollScheduleToToday()
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
initViewState()
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.worldcup-page {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: #fff;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.worldcup-header {
|
|
position: relative;
|
|
background: linear-gradient(180deg, #3552ff 0%, #2c48ea 100%);
|
|
color: #fff;
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
padding-bottom: 2px;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.worldcup-header__bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12rpx 14rpx 0;
|
|
}
|
|
|
|
.worldcup-header__back,
|
|
.worldcup-header__switch {
|
|
width: 56rpx;
|
|
height: 56rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.worldcup-header__switch {
|
|
width: auto;
|
|
min-width: 82rpx;
|
|
padding: 0 14rpx;
|
|
border-radius: 999rpx;
|
|
background: rgba(255, 255, 255, 0.16);
|
|
font-size: 24rpx;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
line-height: 1;
|
|
}
|
|
|
|
.worldcup-header__hero {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0 14rpx 14rpx;
|
|
}
|
|
|
|
.worldcup-header__watermark {
|
|
position: absolute;
|
|
top: -6rpx;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
font-size: 170rpx;
|
|
font-weight: 900;
|
|
line-height: 1;
|
|
color: rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.worldcup-header__badge {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 86rpx;
|
|
height: 86rpx;
|
|
border-radius: 22rpx;
|
|
background: rgba(255, 255, 255, 0.15);
|
|
margin-bottom: 8rpx;
|
|
z-index: 1;
|
|
}
|
|
|
|
.worldcup-header__badge-cup {
|
|
font-size: 34rpx;
|
|
line-height: 1;
|
|
}
|
|
|
|
.worldcup-header__badge-label {
|
|
margin-top: 4rpx;
|
|
font-size: 18rpx;
|
|
font-weight: 600;
|
|
line-height: 1;
|
|
letter-spacing: 1rpx;
|
|
}
|
|
|
|
.worldcup-header__title {
|
|
position: relative;
|
|
z-index: 1;
|
|
font-size: 30rpx;
|
|
font-weight: 600;
|
|
line-height: 1.4;
|
|
text-align: center;
|
|
}
|
|
|
|
.worldcup-nav {
|
|
white-space: nowrap;
|
|
padding: 0 14rpx;
|
|
font-size: 0;
|
|
line-height: 0;
|
|
}
|
|
|
|
.worldcup-nav__inner {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10rpx;
|
|
white-space: nowrap;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.worldcup-nav__item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 10rpx 16rpx;
|
|
border-radius: 24rpx;
|
|
font-size: 24rpx;
|
|
line-height: 1;
|
|
color: rgba(255, 255, 255, 0.9);
|
|
flex-shrink: 0;
|
|
|
|
&--active {
|
|
background: rgba(255, 255, 255, 0.22);
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
}
|
|
|
|
.worldcup-scroll {
|
|
flex: 1;
|
|
height: 0;
|
|
min-height: 0;
|
|
padding: 0 14rpx 18rpx;
|
|
box-sizing: border-box;
|
|
background: #fff;
|
|
}
|
|
|
|
.worldcup-section {
|
|
min-height: 100%;
|
|
}
|
|
|
|
.worldcup-sortbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12rpx 34rpx;
|
|
border-radius: 0;
|
|
background: #fff;
|
|
margin: 0 -14rpx 8rpx;
|
|
}
|
|
|
|
.worldcup-sortbar__label {
|
|
font-size: 26rpx;
|
|
color: #111827;
|
|
}
|
|
|
|
.worldcup-sortbar__icon {
|
|
font-size: 30rpx;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.worldcup-group {
|
|
margin-bottom: 14rpx;
|
|
}
|
|
|
|
.worldcup-group__title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6rpx;
|
|
margin: 4rpx 0 10rpx;
|
|
font-size: 26rpx;
|
|
font-weight: 600;
|
|
color: #111827;
|
|
}
|
|
|
|
.worldcup-group__dot {
|
|
width: 12rpx;
|
|
height: 12rpx;
|
|
border-radius: 50%;
|
|
background: #185dff;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.worldcup-live-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12rpx;
|
|
padding-top: 12rpx;
|
|
}
|
|
|
|
.worldcup-live-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12rpx;
|
|
min-width: 0;
|
|
padding: 20rpx 18rpx;
|
|
border-radius: 22rpx;
|
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
|
box-shadow: 0 10rpx 28rpx rgba(37, 99, 235, 0.08);
|
|
}
|
|
|
|
.worldcup-live-card__player-wrap {
|
|
width: 100%;
|
|
height: 210rpx;
|
|
border-radius: 18rpx;
|
|
overflow: hidden;
|
|
background: #0f172a;
|
|
}
|
|
|
|
.worldcup-live-card__player {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
}
|
|
|
|
.worldcup-live-card__top,
|
|
.worldcup-live-card__meta,
|
|
.worldcup-live-card__footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10rpx;
|
|
}
|
|
|
|
.worldcup-live-card__badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 68rpx;
|
|
height: 36rpx;
|
|
padding: 0 12rpx;
|
|
border-radius: 999rpx;
|
|
background: rgba(53, 82, 255, 0.12);
|
|
color: #3552ff;
|
|
font-size: 22rpx;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.worldcup-live-card__count,
|
|
.worldcup-live-card__meta {
|
|
font-size: 22rpx;
|
|
color: #64748b;
|
|
}
|
|
|
|
.worldcup-live-card__teams {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8rpx;
|
|
min-width: 0;
|
|
}
|
|
|
|
.worldcup-live-card__team {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 24rpx;
|
|
font-weight: 600;
|
|
color: #0f172a;
|
|
text-align: center;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.worldcup-live-card__vs {
|
|
flex-shrink: 0;
|
|
font-size: 22rpx;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.worldcup-live-card__line {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 22rpx;
|
|
color: #1e293b;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.worldcup-live-card__switch {
|
|
flex-shrink: 0;
|
|
color: #3552ff;
|
|
font-size: 22rpx;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.wc-match-card {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 16rpx 12rpx;
|
|
margin-bottom: 8rpx;
|
|
border-radius: 18rpx;
|
|
background: #fff;
|
|
}
|
|
|
|
.wc-match-card__team {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8rpx;
|
|
|
|
&--right {
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
|
|
.wc-match-card__logo {
|
|
width: 46rpx;
|
|
height: 46rpx;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.wc-match-card__team-name {
|
|
font-size: 30rpx;
|
|
font-weight: 600;
|
|
color: #111827;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.wc-match-card__center {
|
|
width: 150rpx;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4rpx;
|
|
}
|
|
|
|
.wc-match-card__time {
|
|
font-size: 32rpx;
|
|
font-weight: 700;
|
|
color: #111827;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.wc-match-card__meta {
|
|
font-size: 20rpx;
|
|
color: #8b93a7;
|
|
line-height: 1.3;
|
|
text-align: center;
|
|
}
|
|
|
|
.bracket-round-nav {
|
|
margin-bottom: 10rpx;
|
|
white-space: nowrap;
|
|
font-size: 0;
|
|
line-height: 0;
|
|
}
|
|
|
|
.bracket-round-nav__inner {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10rpx;
|
|
padding-right: 6rpx;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.bracket-round-nav__item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 8rpx 18rpx;
|
|
border-radius: 24rpx;
|
|
background: #fff;
|
|
color: #7b8190;
|
|
font-size: 24rpx;
|
|
line-height: 1;
|
|
flex-shrink: 0;
|
|
|
|
&--active {
|
|
background: #2f67ff;
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
}
|
|
|
|
.bracket-stage {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.bracket-stage__list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10rpx;
|
|
}
|
|
|
|
.bracket-stage__list--final {
|
|
gap: 8rpx;
|
|
}
|
|
|
|
.bracket-stage__pair {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 8fr) minmax(64rpx, 2fr);
|
|
align-items: stretch;
|
|
column-gap: 0;
|
|
}
|
|
|
|
.bracket-stage__cards {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6rpx;
|
|
min-width: 0;
|
|
}
|
|
|
|
.bracket-stage__linezone {
|
|
position: relative;
|
|
min-width: 64rpx;
|
|
|
|
&::before,
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
border-color: #2f67ff;
|
|
border-style: solid;
|
|
}
|
|
|
|
&::before {
|
|
left: 0;
|
|
top: 70rpx;
|
|
width: 24rpx;
|
|
height: 150rpx;
|
|
border-top-width: 2rpx;
|
|
border-right-width: 2rpx;
|
|
border-bottom-width: 2rpx;
|
|
border-left-width: 0;
|
|
border-radius: 0 18rpx 18rpx 0;
|
|
}
|
|
|
|
&::after {
|
|
left: 24rpx;
|
|
top: 145rpx;
|
|
width: calc(100% - 24rpx);
|
|
height: 1px;
|
|
background: #2f67ff;
|
|
border: 0;
|
|
transform: translateY(-50%);
|
|
}
|
|
}
|
|
|
|
.bracket-stage__item {
|
|
position: relative;
|
|
}
|
|
|
|
.bracket-stage__card {
|
|
display: flex;
|
|
align-items: center;
|
|
min-height: 140rpx;
|
|
padding: 14rpx 14rpx;
|
|
border-radius: 24rpx;
|
|
background: #fff;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.bracket-stage__time {
|
|
width: 86rpx;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 8rpx;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.bracket-stage__clock {
|
|
font-size: 30rpx;
|
|
font-weight: 600;
|
|
line-height: 1;
|
|
color: #111827;
|
|
}
|
|
|
|
.bracket-stage__date {
|
|
font-size: 18rpx;
|
|
line-height: 1;
|
|
color: #8b93a7;
|
|
}
|
|
|
|
.bracket-stage__divider {
|
|
width: 1rpx;
|
|
align-self: stretch;
|
|
margin: 0 12rpx 0 10rpx;
|
|
background: #edf0f5;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.bracket-stage__teams {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 8rpx;
|
|
}
|
|
|
|
.bracket-stage__team {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10rpx;
|
|
}
|
|
|
|
.bracket-stage__logo {
|
|
width: 42rpx;
|
|
height: 22rpx;
|
|
flex-shrink: 0;
|
|
background: #f2f4f7;
|
|
border-radius: 2rpx;
|
|
}
|
|
|
|
.bracket-stage__name {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 28rpx;
|
|
line-height: 1.3;
|
|
color: #111827;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.bracket-stage__name--multi {
|
|
white-space: normal;
|
|
display: -webkit-box;
|
|
-webkit-box-orient: vertical;
|
|
-webkit-line-clamp: 2;
|
|
}
|
|
|
|
.bracket-extra {
|
|
margin-top: 10rpx;
|
|
}
|
|
|
|
.bracket-extra__title {
|
|
display: block;
|
|
margin-bottom: 8rpx;
|
|
font-size: 24rpx;
|
|
font-weight: 500;
|
|
color: #111827;
|
|
}
|
|
|
|
.worldcup-empty,
|
|
.worldcup-loading {
|
|
padding: 80rpx 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.worldcup-loading {
|
|
flex-direction: column;
|
|
gap: 12rpx;
|
|
color: #8b93a7;
|
|
font-size: 24rpx;
|
|
}
|
|
</style>
|