feat: add match favorites

This commit is contained in:
hajimi
2026-08-03 21:31:32 +08:00
parent 1e4a62b756
commit 17720e86e2
4 changed files with 308 additions and 10 deletions
@@ -1,15 +1,23 @@
<template>
<view class="detail-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="detail-header__nav">
<view class="detail-header__back" @tap="$emit('back')">
<u-icon name="arrow-left" size="40" color="#333"></u-icon>
<view class="detail-header__left">
<view class="detail-header__back" @tap="$emit('back')">
<u-icon name="arrow-left" size="40" color="#333"></u-icon>
</view>
</view>
<view class="detail-header__title-wrap">
<text class="detail-header__title">{{ title }}</text>
<text v-if="subtitle" class="detail-header__subtitle">{{ subtitle }}</text>
</view>
<view class="detail-header__share" @tap="$emit('share')">
<u-icon name="share" size="36" color="#333"></u-icon>
<view class="detail-header__actions">
<view class="detail-header__favorite" @tap="$emit('favorite')">
<u-icon :name="favorite ? 'star-fill' : 'star'" size="34"
:color="favorite ? '#e6a23c' : '#333'"></u-icon>
</view>
<view class="detail-header__share" @tap="$emit('share')">
<u-icon name="share" size="36" color="#333"></u-icon>
</view>
</view>
</view>
</view>
@@ -20,10 +28,12 @@ defineProps<{
title?: string
subtitle?: string
statusBarHeight?: number
favorite?: boolean
}>()
defineEmits<{
(e: 'back'): void
(e: 'favorite'): void
(e: 'share'): void
}>()
</script>
@@ -40,12 +50,14 @@ defineEmits<{
&__nav {
position: relative;
z-index: 1;
display: flex;
display: grid;
grid-template-columns: 144rpx minmax(0, 1fr) 144rpx;
align-items: center;
height: 96rpx;
}
&__back,
&__favorite,
&__share {
width: 72rpx;
height: 72rpx;
@@ -56,8 +68,18 @@ defineEmits<{
background: transparent;
}
&__left,
&__actions {
display: flex;
align-items: center;
min-width: 0;
}
&__actions {
justify-content: flex-end;
}
&__title-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
+183 -2
View File
@@ -31,6 +31,7 @@
<u-icon :name="tab.icon" size="28" :color="activeType === tab.key ? '#1468f5' : '#7b8492'" />
<text>{{ tab.label }}</text>
<text v-if="tab.key === 'article' && articleCount" class="collection-tab__count">{{ articleCount }}</text>
<text v-if="tab.key === 'match' && matchCount" class="collection-tab__count">{{ matchCount }}</text>
<text v-if="tab.key === 'lottery' && lotteryCount" class="collection-tab__count">{{ lotteryCount }}</text>
<text v-if="tab.key === 'crypto' && cryptoCount" class="collection-tab__count">{{ cryptoCount }}</text>
</view>
@@ -76,6 +77,45 @@
</view>
</template>
<template v-else-if="activeType === 'match'">
<view v-if="matchFavorites.length" class="collection-list match-list">
<view v-for="item in matchFavorites" :key="item.id" class="match-item" @tap="openMatch(item)">
<view class="match-item__top">
<text class="match-item__league">{{ item.league_name || '赛事' }}{{ item.round_name ? ` · ${item.round_name}` : '' }}</text>
<text class="match-item__status">{{ getMatchFavoriteStatus(item) }}</text>
</view>
<view class="match-item__content">
<view class="match-item__team">
<image v-if="item.home_icon" :src="item.home_icon" mode="aspectFit" />
<view v-else class="match-item__team-logo">{{ item.home_team.slice(0, 1) || '?' }}</view>
<text>{{ item.home_team || '主队' }}</text>
</view>
<view class="match-item__center">
<view v-if="item.status !== 0" class="match-item__score">
<text>{{ item.home_score ?? 0 }}</text>
<text>:</text>
<text>{{ item.away_score ?? 0 }}</text>
</view>
<text v-else class="match-item__time">{{ formatMatchFavoriteTime(item) }}</text>
</view>
<view class="match-item__team">
<image v-if="item.away_icon" :src="item.away_icon" mode="aspectFit" />
<view v-else class="match-item__team-logo">{{ item.away_team.slice(0, 1) || '?' }}</view>
<text>{{ item.away_team || '客队' }}</text>
</view>
<view class="match-item__remove" @tap.stop="removeMatch(item)">
<u-icon name="star-fill" size="28" color="#e6a23c" />
</view>
</view>
</view>
</view>
<view v-else class="collection-state collection-state--empty">
<u-icon name="calendar" size="72" color="#cbd5e1" />
<text class="collection-state__title">暂无比赛收藏</text>
<text class="collection-state__desc">在赛事详情页点击星标即可加入收藏</text>
</view>
</template>
<template v-else-if="activeType === 'lottery'">
<view v-if="lotteryFavorites.length" class="collection-list lottery-list">
<view v-for="item in lotteryFavorites" :key="item.code" class="lottery-item"
@@ -155,6 +195,8 @@ import {
type CryptoMarketCoin
} from '@/api/crypto'
import { getLotteryFavoriteCodes, setLotteryFavoriteCodes } from '@/utils/lottery-favorite'
import { getMatchFavorites, setMatchFavorites, type MatchFavoriteItem } from '@/utils/match-favorite'
import { getLocalMatchDate } from '@/utils/match-time'
type CollectionType = 'article' | 'post' | 'match' | 'lottery' | 'crypto'
@@ -168,15 +210,17 @@ const collectionTabs: Array<{ key: CollectionType; label: string; icon: string }
const activeType = ref<CollectionType>('article')
const articles = ref<any[]>([])
const matchFavorites = ref<MatchFavoriteItem[]>(getMatchFavorites())
const lotteryFavorites = ref<any[]>(getLotteryFavoriteCodes().map((code) => ({ code })))
const coins = ref<CryptoMarketCoin[]>([])
const loading = ref(false)
const refreshing = ref(false)
const articleCount = computed(() => articles.value.length)
const matchCount = computed(() => matchFavorites.value.length)
const lotteryCount = computed(() => lotteryFavorites.value.length)
const cryptoCount = computed(() => coins.value.length)
const collectionCount = computed(() => articleCount.value + lotteryCount.value + cryptoCount.value)
const collectionCount = computed(() => articleCount.value + matchCount.value + lotteryCount.value + cryptoCount.value)
const activeTabLabel = computed(() => collectionTabs.find((tab) => tab.key === activeType.value)?.label || '')
const loadArticles = async () => {
@@ -195,6 +239,10 @@ const loadCrypto = async () => {
: []
}
const loadMatches = () => {
matchFavorites.value = getMatchFavorites()
}
const loadLottery = async () => {
const favoriteCodes = getLotteryFavoriteCodes()
if (!favoriteCodes.length) {
@@ -234,6 +282,8 @@ const loadCollection = async () => {
try {
if (activeType.value === 'article') {
await loadArticles()
} else if (activeType.value === 'match') {
loadMatches()
} else if (activeType.value === 'lottery') {
await loadLottery()
} else if (activeType.value === 'crypto') {
@@ -276,6 +326,31 @@ const removeArticle = async (item: any) => {
}
}
const openMatch = (item: MatchFavoriteItem) => {
uni.navigateTo({ url: `/pages/match_detail/match_detail?id=${item.id}` })
}
const removeMatch = (item: MatchFavoriteItem) => {
matchFavorites.value = setMatchFavorites(matchFavorites.value.filter((favorite) => favorite.id !== item.id))
uni.showToast({ title: '已取消收藏', icon: 'none' })
}
const getMatchFavoriteStatus = (item: MatchFavoriteItem) => {
if (item.status === 1) return item.current_minute || '直播中'
if (item.status === 0) return '未开始'
return '已结束'
}
const formatMatchFavoriteTime = (item: MatchFavoriteItem) => {
const date = getLocalMatchDate(item.match_time, item)
if (!date) return '时间待定'
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}`
}
const openCoin = (coin: CryptoMarketCoin) => {
uni.navigateTo({ url: `/pages/crypto/crypto_detail?symbol=${encodeURIComponent(coin.symbol)}` })
}
@@ -320,7 +395,7 @@ const formatChange = (value: number) => {
}
onShow(() => {
if (activeType.value === 'article' || activeType.value === 'lottery' || activeType.value === 'crypto') {
if (activeType.value === 'article' || activeType.value === 'match' || activeType.value === 'lottery' || activeType.value === 'crypto') {
loadCollection()
}
})
@@ -600,6 +675,112 @@ onShow(() => {
}
}
.match-item {
margin-bottom: 16rpx;
padding: 20rpx;
background: #fff;
border: 1rpx solid #e7eaf0;
border-radius: 22rpx;
box-shadow: 0 8rpx 30rpx rgba(29, 45, 78, 0.04);
animation: collection-rise 0.32s ease both;
&__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 18rpx;
}
&__league {
min-width: 0;
overflow: hidden;
color: #235bde;
font-size: 22rpx;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
&__status {
flex-shrink: 0;
color: #7d8797;
font-size: 21rpx;
}
&__content {
display: grid;
grid-template-columns: minmax(0, 1fr) 130rpx minmax(0, 1fr) 42rpx;
align-items: center;
gap: 10rpx;
}
&__team {
min-width: 0;
display: flex;
align-items: center;
gap: 10rpx;
color: #202633;
font-size: 24rpx;
font-weight: 600;
image,
&-logo {
width: 46rpx;
height: 46rpx;
flex-shrink: 0;
}
image {
object-fit: contain;
}
text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__team-logo {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #edf3ff;
color: #1468f5;
font-size: 22rpx;
}
&__center {
min-width: 0;
text-align: center;
}
&__score {
display: flex;
align-items: center;
justify-content: center;
gap: 7rpx;
color: #151b26;
font-size: 30rpx;
font-weight: 700;
}
&__time {
display: block;
color: #7d8797;
font-size: 19rpx;
line-height: 1.3;
}
&__remove {
display: flex;
justify-content: flex-end;
}
}
.lottery-item {
display: flex;
align-items: center;
+16 -2
View File
@@ -1,7 +1,7 @@
<template>
<view class="match-detail">
<match-detail-header :status-bar-height="statusBarHeight" :title="matchDetailTitle" @back="goBack"
@share="showSharePopup = true" />
:favorite="isFavorite" @favorite="toggleFavorite" @share="showSharePopup = true" />
<!-- 加载中 -->
<view class="detail-loading" v-if="loading">
@@ -463,13 +463,14 @@ import MatchDetailHeader from '@/components/match-detail-header/match-detail-hea
import MatchOddsDetail from '@/components/match-odds-detail/match-odds-detail.vue'
import SharePopup from '@/components/share-popup/share-popup.vue'
import GlobalPopup from '@/components/global-popup/global-popup.vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { getMatchDetail, getMatchLiveText, getMatchLineup } from '@/api/match'
import type { MatchLiveLineItem } from '@/api/match'
import { checkAiUnlock, getMatchPredictSection } from '@/api/ai'
import { useUserStore } from '@/stores/user'
import { getLocalMatchDate } from '@/utils/match-time'
import { AI_DAILY_FREE_TEXT, getAiFreeRemain } from '@/config/ai'
import { isMatchFavorite, toggleMatchFavorite } from '@/utils/match-favorite'
type MatchDetailLiveLine = MatchLiveLineItem & {
lineKey: string
@@ -494,6 +495,7 @@ const aiUnlocked = ref(false)
const aiInlineLoading = ref(false)
const aiInline = ref<any>({})
const showSharePopup = ref(false)
const isFavorite = ref(false)
const liveTextList = ref<any[]>([])
const activeLiveLineKey = ref('')
const activeLiveError = ref('')
@@ -741,6 +743,7 @@ const fetchDetail = async (id: number) => {
try {
const data = await getMatchDetail({ id })
match.value = data || {}
isFavorite.value = isMatchFavorite(match.value?.id || id)
if (data?.live_text?.length) {
liveTextList.value = data.live_text
}
@@ -883,6 +886,13 @@ const goBack = () => {
uni.navigateBack()
}
const toggleFavorite = () => {
if (!match.value?.id) return
const result = toggleMatchFavorite(match.value)
isFavorite.value = result.favorite
uni.showToast({ title: result.favorite ? '已收藏比赛' : '已取消收藏', icon: 'none' })
}
const goAiAnalysis = () => {
const id = match.value?.id
if (!id) return
@@ -973,6 +983,10 @@ onLoad((options) => {
}
})
onShow(() => {
if (match.value?.id) isFavorite.value = isMatchFavorite(match.value.id)
})
onUnload(() => {
stopLiveTextPolling()
})
+81
View File
@@ -0,0 +1,81 @@
const MATCH_FAVORITE_STORAGE_KEY = 'match_favorites'
export interface MatchFavoriteItem {
id: number
league_name: string
round_name: string
home_team: string
home_icon: string
home_score: number | string
away_team: string
away_icon: string
away_score: number | string
match_time: number | string
status: number
current_minute: string
}
const normalizeFavorite = (value: any): MatchFavoriteItem | null => {
const id = Number(value?.id)
if (!Number.isFinite(id) || id <= 0) return null
return {
id,
league_name: String(value?.league_name || ''),
round_name: String(value?.round_name || ''),
home_team: String(value?.home_team || ''),
home_icon: String(value?.home_icon || ''),
home_score: value?.home_score ?? 0,
away_team: String(value?.away_team || ''),
away_icon: String(value?.away_icon || ''),
away_score: value?.away_score ?? 0,
match_time: value?.match_time ?? '',
status: Number(value?.status || 0),
current_minute: String(value?.current_minute || '')
}
}
export const getMatchFavorites = (): MatchFavoriteItem[] => {
const stored = uni.getStorageSync(MATCH_FAVORITE_STORAGE_KEY)
if (!Array.isArray(stored)) return []
const seen = new Set<number>()
return stored.reduce<MatchFavoriteItem[]>((favorites, item) => {
const favorite = normalizeFavorite(item)
if (!favorite || seen.has(favorite.id)) return favorites
seen.add(favorite.id)
favorites.push(favorite)
return favorites
}, [])
}
export const setMatchFavorites = (favorites: MatchFavoriteItem[]): MatchFavoriteItem[] => {
const seen = new Set<number>()
const normalized = favorites.reduce<MatchFavoriteItem[]>((items, favorite) => {
const normalizedFavorite = normalizeFavorite(favorite)
if (!normalizedFavorite || seen.has(normalizedFavorite.id)) return items
seen.add(normalizedFavorite.id)
items.push(normalizedFavorite)
return items
}, [])
uni.setStorageSync(MATCH_FAVORITE_STORAGE_KEY, normalized)
return normalized
}
export const isMatchFavorite = (matchId: number | string): boolean => {
const id = Number(matchId)
return getMatchFavorites().some((favorite) => favorite.id === id)
}
export const toggleMatchFavorite = (match: any): { favorite: boolean; favorites: MatchFavoriteItem[] } => {
const item = normalizeFavorite(match)
const favorites = getMatchFavorites()
if (!item) return { favorite: false, favorites }
const favorite = favorites.some((existing) => existing.id === item.id)
const nextFavorites = favorite
? favorites.filter((existing) => existing.id !== item.id)
: [item, ...favorites]
return { favorite: !favorite, favorites: setMatchFavorites(nextFavorites) }
}