feat: add match favorites
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
Reference in New Issue
Block a user