feat: redesign match center schedule page

This commit is contained in:
hajimi
2026-08-02 02:29:15 +08:00
parent 18b7f843ae
commit b5b59d7bf1
3 changed files with 697 additions and 174 deletions
@@ -5,13 +5,34 @@
:class="{ 'content-tabs__item--active': activeTab === tab.key }" @tap="switchContentTab(tab.key)">
<text>{{ tab.label }}</text>
</view>
<view class="content-tabs__item" @tap="emit('open-worldcup')">
<text>热门联赛</text>
</view>
</view>
<view v-if="activeTab === 'schedule'" class="date-filter">
<view class="date-filter__previous" @tap="shiftDateWindow(-1)">
<text></text>
</view>
<view class="date-filter__dates">
<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>
<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 sportTabs" :key="idx" class="sport-tab"
:class="{ 'sport-tab--active': currentSport === tab.value }" @tap="switchSport(tab.value)">
<text class="sport-tab__icon">{{ tab.icon }}</text>
<text class="sport-tab__label">{{ tab.label }}</text>
</view>
</view>
@@ -21,13 +42,12 @@
<view class="league-tabs__inner">
<view class="league-tab" :class="{ 'league-tab--active': currentLeague === '' }"
@tap="switchLeague('')">
<text>全部<text v-if="leagueTotalCount" class="league-tab__count">·{{ leagueTotalCount }}</text></text>
<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 v-if="league.count" class="league-tab__count">·{{ league.count
}}</text></text>
<text>{{ league.league_name }}</text>
</view>
</view>
</scroll-view>
@@ -40,15 +60,15 @@
<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__arrow"
:class="{ 'match-group__arrow--collapsed': collapsedGroups[group.type] }">
<text></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)">
<match-card :match="item"
:show-live-ai="group.type === 'live' || group.type === 'upcoming'" />
<MatchCenterScheduleCard :match="item" @detail="goDetail(item)" @ai="goAiAnalysis(item)" />
</view>
</view>
@@ -56,7 +76,7 @@
<u-loading mode="circle" />
<text>加载中...</text>
</view>
<view v-if="!scheduleLoading && !liveList.length && !upcomingList.length && !endedList.length"
<view v-if="!scheduleLoading && !matchGroups.length"
class="content-empty">
<u-empty text="暂无赛事" mode="data" />
</view>
@@ -227,13 +247,16 @@
<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 MatchCard from '@/components/match-card/match-card.vue'
import MatchCenterScheduleCard from './MatchCenterScheduleCard.vue'
import { getLocalMatchDate } from '@/utils/match-time'
import {
getMatchLeagues,
getMatchList,
@@ -248,6 +271,10 @@ type MatchCenterTab = 'schedule' | 'live' | 'odds'
type OddsShowType = '' | 'live' | 'today' | 'early'
type OddsPlatformOption = { key: string; label: string }
const emit = defineEmits<{
(e: 'open-worldcup'): void
}>()
const showStandaloneOddsTab = false
const allContentTabs: { key: MatchCenterTab; label: string }[] = [
@@ -275,6 +302,8 @@ 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[]>([])
@@ -308,6 +337,83 @@ const collapsedGroups = ref<Record<string, boolean>>({
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 [-1, 0, 1].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[]) => {
const dateFiltered = selectedDateKey.value
? items.filter((item: any) => getMatchDateKey(item) === selectedDateKey.value)
: items
if (currentLeague.value) return dateFiltered
const hotItems = dateFiltered.filter((item: any) => Number(item?.is_hot) === 1)
return hotItems.length ? hotItems : dateFiltered
}
const leagueTabs = computed(() => {
if (currentSport.value === 0) {
const merged: any[] = []
@@ -320,20 +426,19 @@ const leagueTabs = computed(() => {
return allLeagues.value[currentSport.value] || []
})
const leagueTotalCount = computed(() => {
return leagueTabs.value.reduce((sum: number, league: any) => sum + (league.count || 0), 0)
})
const matchGroups = computed(() => {
const groups: { title: string; type: string; items: any[]; order: number }[] = []
if (liveList.value.length > 0) {
groups.push({ title: '进行中', type: 'live', items: liveList.value, order: 0 })
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 (upcomingList.value.length > 0) {
groups.push({ title: '开始', type: 'upcoming', items: upcomingList.value, order: 1 })
if (visibleUpcomingList.length > 0) {
groups.push({ title: '即将开始', type: 'upcoming', items: visibleUpcomingList, order: 1 })
}
if (endedList.value.length > 0) {
groups.push({ title: '已结束', type: 'ended', items: endedList.value, order: 2 })
if (visibleEndedList.length > 0) {
groups.push({ title: '已结束', type: 'ended', items: visibleEndedList, order: 2 })
}
return groups
})
@@ -362,7 +467,11 @@ const initLayout = () => {
const fetchSportTypes = async () => {
try {
sportTabs.value = await getSportTypes() || []
const types = await getSportTypes() || []
sportTabs.value = [
{ value: 0, label: '全部' },
...types.filter((item: any) => Number(item?.value || 0) > 0),
]
} catch (error) {
console.error('获取运动类型失败', error)
}
@@ -401,6 +510,7 @@ const fetchMatchList = async (refresh = false) => {
}
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)
@@ -534,6 +644,12 @@ const goDetail = (item: any) => {
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)
@@ -690,74 +806,160 @@ onMounted(() => {
min-height: 0;
display: flex;
flex-direction: column;
background: #fff;
background: #f7f8fb;
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
}
.content-tabs {
padding: 0 24rpx 14rpx;
margin: 0 34rpx 16rpx;
padding: 8rpx;
display: flex;
gap: 12rpx;
gap: 8rpx;
border: 1rpx solid #dfe3eb;
border-radius: 24rpx;
background: #fff;
&__item {
flex: 1;
min-width: 0;
height: 58rpx;
border-radius: 16rpx;
background: #f1f5ff;
color: #185dff;
font-size: 26rpx;
height: 70rpx;
border-radius: 18rpx;
color: #404654;
font-size: 28rpx;
font-weight: 700;
line-height: 58rpx;
line-height: 70rpx;
text-align: center;
}
&__item--active {
background: #185dff;
background: linear-gradient(110deg, #1875ff 0%, #0863f3 100%);
color: #fff;
box-shadow: 0 8rpx 20rpx rgba(24, 93, 255, 0.2);
box-shadow: 0 8rpx 20rpx rgba(20, 105, 246, 0.2);
}
}
.date-filter {
height: 94rpx;
padding: 0 32rpx;
display: flex;
align-items: center;
gap: 12rpx;
background: #fff;
&__previous,
&__calendar {
width: 58rpx;
height: 58rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__previous {
color: #20283a;
font-size: 58rpx;
font-weight: 300;
line-height: 1;
}
&__calendar {
border: 1rpx solid #e0e4eb;
border-radius: 16rpx;
background: #fff;
}
&__dates {
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
}
&__date {
position: relative;
height: 70rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
border-radius: 16rpx;
color: #5b606b;
&::after {
content: '';
position: absolute;
left: 50%;
bottom: -9rpx;
width: 34rpx;
height: 5rpx;
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: 29rpx;
font-weight: 700;
}
&__date-week {
font-size: 22rpx;
}
}
.sport-tabs {
white-space: nowrap;
height: 80rpx;
border-bottom: 1rpx solid #f0f0f0;
height: 94rpx;
background: #fff;
}
.sport-tabs__inner {
display: inline-flex;
align-items: center;
height: 60rpx;
padding: 0 12rpx;
gap: 4rpx;
height: 94rpx;
padding: 0 32rpx;
gap: 28rpx;
box-sizing: border-box;
}
.sport-tab {
display: inline-flex;
align-items: center;
padding: 6rpx 16rpx;
border-radius: 18rpx;
background: #f4f5f7;
justify-content: center;
min-width: 116rpx;
height: 62rpx;
padding: 0 18rpx;
border-radius: 16rpx;
background: #f5f6f9;
white-space: nowrap;
flex-shrink: 0;
&__icon {
font-size: 32rpx;
line-height: 1;
}
&__label {
font-size: 28rpx;
color: #666;
font-size: 27rpx;
color: #313745;
font-weight: 600;
line-height: 1.4;
}
&--active {
background: #e5edff;
background: linear-gradient(110deg, #1875ff 0%, #0863f3 100%);
box-shadow: 0 8rpx 18rpx rgba(20, 105, 246, 0.17);
.sport-tab__label {
color: #185dff;
font-weight: 500;
color: #fff;
font-weight: 700;
}
}
}
@@ -765,13 +967,14 @@ onMounted(() => {
.league-tabs {
white-space: nowrap;
background: #fff;
border-bottom: 1rpx solid #eceef3;
}
.league-tabs__inner {
display: inline-flex;
align-items: center;
padding: 8rpx 16rpx;
gap: 4rpx;
padding: 0 32rpx 22rpx;
gap: 28rpx;
white-space: nowrap;
}
@@ -779,45 +982,48 @@ onMounted(() => {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8rpx 18rpx;
border-radius: 18rpx;
font-size: 24rpx;
color: #808080;
background: #fff;
min-width: 116rpx;
height: 62rpx;
padding: 0 18rpx;
border: 1rpx solid transparent;
border-radius: 16rpx;
font-size: 26rpx;
color: #3f4552;
background: #f5f6f9;
white-space: nowrap;
flex-shrink: 0;
box-sizing: border-box;
&--active {
background: #185dff;
color: #fff;
border-color: #1470f8;
background: #fff;
color: #1470f8;
font-weight: 700;
}
&__count {
font-size: 24rpx;
}
}
.match-content {
flex: 1;
height: 0;
min-height: 0;
background: #fff;
background: #f7f8fb;
}
.match-group {
margin-bottom: 2rpx;
padding-top: 14rpx;
&__header {
display: flex;
align-items: center;
padding: 18rpx 18rpx 8rpx;
gap: 6rpx;
background: #fff;
padding: 14rpx 34rpx 16rpx;
gap: 10rpx;
background: #f7f8fb;
}
&__dot {
width: 14rpx;
height: 14rpx;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
&--live {
@@ -834,36 +1040,45 @@ onMounted(() => {
}
&__title {
font-size: 30rpx;
font-weight: 600;
color: #333;
font-size: 31rpx;
font-weight: 800;
color: #222735;
}
&__count {
font-size: 20rpx;
color: #999;
font-size: 23rpx;
color: #7f8592;
}
&__more {
margin-left: auto;
display: flex;
align-items: center;
gap: 10rpx;
color: #747b88;
font-size: 23rpx;
}
&__arrow {
margin-left: auto;
font-size: 20rpx;
color: #999;
color: #737b89;
font-size: 42rpx;
font-weight: 300;
line-height: 1;
transition: transform 0.25s ease;
&--collapsed {
transform: rotate(-90deg);
}
}
&--ended,
&--ended .match-group__header {
background: #f3f4f6;
&__more--collapsed &__arrow {
transform: rotate(-90deg);
}
}
.match-card-wrap {
position: relative;
margin: 0 18rpx 8rpx;
margin: 0 34rpx 22rpx;
}
.match-content__bottom-space {
height: calc(120rpx + env(safe-area-inset-bottom));
}
.content-loading,