Files
sbnews/uniapp/src/pages/index/index.vue
T

828 lines
24 KiB
Vue

<template>
<view class="index">
<view class="top-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="top-bar__content">
<view class="top-bar__logo">
<image src="/static/images/logo.png" mode="aspectFit" class="top-bar__logo-img" />
</view>
<view class="top-bar__search" @tap="goSearch">
<UIcon name="search" size="30" color="#8C95A5" />
<text class="top-bar__search-text">搜索赛事球队球员</text>
</view>
<AiAssistantEntry size="compact" variant="orb" />
</view>
</view>
<view class="channel-bar">
<scroll-view scroll-x class="channel-bar__scroll" show-scrollbar="false">
<view class="channel-bar__list">
<view v-for="item in primaryChannels" :key="item.channelKey" class="channel-bar__item"
:class="{ 'channel-bar__item--active': currentChannel === item.index }" @tap="switchChannel(item.index)">
<text>{{ item.name }}</text>
</view>
</view>
</scroll-view>
<view class="channel-bar__more" @tap="goChannelManage">
<text>更多</text>
<UIcon name="arrow-down" size="22" color="#667085" />
</view>
</view>
<scroll-view scroll-y class="feed-wrapper" @scrolltolower="loadMore" :lower-threshold="100">
<view class="home-content">
<view v-if="focusMatches.length" class="focus-section">
<view class="section-heading">
<text class="section-heading__title">今日焦点</text>
<view class="section-heading__action" @tap="goAllMatches">
<text>全部赛程</text>
<UIcon name="arrow-right" size="22" color="#1468F5" />
</view>
</view>
<view class="focus-section__list">
<view v-for="(match, index) in focusMatches" :key="match.id" class="focus-match"
@tap="goMatch(match.id)">
<view class="focus-match__head">
<text class="focus-match__league" :class="{ 'focus-match__league--warm': index % 2 }">
{{ match.league_name || '赛事' }}
</text>
<text class="focus-match__time">{{ formatFocusMeta(match) }}</text>
</view>
<view class="focus-match__teams">
<view class="focus-match__team">
<image v-if="match.home_icon" :src="match.home_icon" mode="aspectFit"
class="focus-match__badge" referrerpolicy="no-referrer" />
<view v-else class="focus-match__badge focus-match__badge--fallback">
<text>{{ getTeamInitial(match.home_team) }}</text>
</view>
<text class="focus-match__team-name">{{ match.home_team || '主队' }}</text>
</view>
<text class="focus-match__versus">VS</text>
<view class="focus-match__team">
<image v-if="match.away_icon" :src="match.away_icon" mode="aspectFit"
class="focus-match__badge" referrerpolicy="no-referrer" />
<view v-else class="focus-match__badge focus-match__badge--fallback">
<text>{{ getTeamInitial(match.away_team) }}</text>
</view>
<text class="focus-match__team-name">{{ match.away_team || '客队' }}</text>
</view>
</view>
</view>
</view>
</view>
<navigator v-if="featuredArticle" :url="`/pages/news_detail/news_detail?id=${featuredArticle.id}`"
class="featured-card">
<image v-if="featuredArticle.image && !featuredImageFailed" class="featured-card__image"
:src="featuredArticle.image" mode="aspectFill" referrerpolicy="no-referrer"
@error="featuredImageFailed = true" />
<view class="featured-card__shade" />
<view class="featured-card__content">
<text class="featured-card__tag">{{ getArticleCategoryName(featuredArticle) }}</text>
<view class="featured-card__copy">
<text class="featured-card__title">{{ featuredArticle.title }}</text>
<text v-if="getArticleSummary(featuredArticle)" class="featured-card__desc">
{{ getArticleSummary(featuredArticle) }}
</text>
</view>
<view class="featured-card__meta">
<text>{{ getArticleAuthor(featuredArticle) }}</text>
<text>{{ formatRelativeTime(featuredArticle.create_time) }}</text>
</view>
</view>
</navigator>
<view class="latest-section">
<view class="section-heading latest-section__heading">
<text class="section-heading__title">最新资讯</text>
<view class="latest-section__filter">
<UIcon name="filter" size="28" color="#7A8494" />
<text>筛选</text>
</view>
</view>
<view class="latest-section__list">
<FeedCard v-for="item in regularFeedList" :key="item.id" :item="item" />
</view>
</view>
<view v-if="loading" class="feed-loading">
<ULoading mode="circle" />
<text class="feed-loading__text">加载中...</text>
</view>
<view v-if="noMore && feedList.length" class="feed-bottom">
<text>没有更多了</text>
</view>
<view v-if="!loading && !feedList.length" class="feed-empty">
<text>暂无内容</text>
</view>
</view>
</scroll-view>
<!-- #ifdef MP -->
<MpPrivacyPopup />
<!-- #endif -->
<GlobalPopup />
</view>
</template>
<script setup lang="ts">
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { getArticleList, getArticleCate } from '@/api/news'
import { getMatchList } from '@/api/match'
import { resolveShortLink } from '@/api/share'
import { getLocalMatchDate } from '@/utils/match-time'
import { useUserStore } from '@/stores/user'
import AiAssistantEntry from '@/components/ai-assistant-entry/ai-assistant-entry.vue'
import FeedCard from '@/components/feed-card/feed-card.vue'
import GlobalPopup from '@/components/global-popup/global-popup.vue'
import UIcon from '@/uni_modules/vk-uview-ui/components/u-icon/u-icon.vue'
import ULoading from '@/uni_modules/vk-uview-ui/components/u-loading/u-loading.vue'
const userStore = useUserStore()
// #ifdef MP
import MpPrivacyPopup from './component/mp-privacy-popup.vue'
// #endif
const statusBarHeight = ref(0)
uni.getSystemInfo({
success: (res) => {
// #ifdef APP-PLUS || MP
statusBarHeight.value = res.statusBarHeight || 44
// #endif
}
})
const RECOMMEND_CHANNEL = {
id: '__recommend__',
name: '推荐',
specialType: 'recommend',
channelKey: '__recommend__'
}
const MY_CHANNEL = {
id: '__mychannel__',
name: '我的频道',
specialType: 'mychannel',
channelKey: '__mychannel__'
}
const articleChannels = ref<any[]>([])
const allChannels = ref<any[]>([])
const currentChannel = ref(0)
const feedList = ref<any[]>([])
const focusMatches = ref<any[]>([])
const featuredImageFailed = ref(false)
const loading = ref(false)
const noMore = ref(false)
const pageNo = ref(1)
const pageSize = 10
const primaryChannels = computed(() => {
return allChannels.value
.map((item, index) => ({ ...item, index }))
.filter((item) => item.specialType !== 'mychannel')
.slice(0, 5)
})
const featuredArticle = computed(() => {
return feedList.value.find((item) => item.image) || feedList.value[0] || null
})
const regularFeedList = computed(() => {
const featuredId = featuredArticle.value?.id
return feedList.value.filter((item) => item.id !== featuredId)
})
const buildAllChannels = (channels: any[] = []) => {
const merged = [{ ...RECOMMEND_CHANNEL }]
if (userStore.isLogin) {
merged.push({ ...MY_CHANNEL })
}
const dynamicChannels = (channels || []).map((item: any) => ({
...item,
specialType: 'category',
channelKey: `cate_${item.id}`
}))
return [...merged, ...dynamicChannels]
}
const syncAllChannels = (channels: any[] = articleChannels.value) => {
const previousKey = allChannels.value[currentChannel.value]?.channelKey || RECOMMEND_CHANNEL.channelKey
allChannels.value = buildAllChannels(channels)
const nextIndex = allChannels.value.findIndex((item: any) => item.channelKey === previousKey)
currentChannel.value = nextIndex >= 0 ? nextIndex : 0
}
const findChannelIndexById = (id: any) => {
return allChannels.value.findIndex((item: any) => {
return item.specialType === 'category' && String(item.id) === String(id)
})
}
const loadChannels = async () => {
try {
articleChannels.value = (await getArticleCate()) || []
} catch (error) {
console.log('获取分类失败=>', error)
articleChannels.value = []
} finally {
syncAllChannels()
}
}
const loadFocusMatches = async () => {
try {
const data = await getMatchList()
const priorityMatches = [...(data?.live || []), ...(data?.upcoming || [])]
focusMatches.value = priorityMatches.slice(0, 2)
} catch (error) {
console.log('获取首页焦点赛程失败=>', error)
focusMatches.value = []
}
}
const switchChannel = (index: number) => {
if (currentChannel.value === index) return
currentChannel.value = index
reloadFeed()
}
const reloadFeed = () => {
pageNo.value = 1
noMore.value = false
feedList.value = []
featuredImageFailed.value = false
fetchFeedList()
}
const fetchFeedList = async () => {
if (loading.value || noMore.value) return
loading.value = true
try {
const params: Record<string, any> = {
page_no: pageNo.value,
page_size: pageSize
}
const currentChannelItem = allChannels.value[currentChannel.value]
if (!currentChannelItem || currentChannelItem.specialType === 'recommend') {
params.is_recommend = 1
} else if (currentChannelItem.specialType === 'mychannel') {
params.is_mychannel = 1
} else if (currentChannelItem.id) {
params.cid = currentChannelItem.id
}
const { lists, count } = await getArticleList(params)
const newList = lists || []
if (pageNo.value === 1) {
feedList.value = newList
featuredImageFailed.value = false
} else {
feedList.value = [...feedList.value, ...newList]
}
if (feedList.value.length >= count || newList.length < pageSize) {
noMore.value = true
}
pageNo.value++
} catch (error) {
console.log('首页资讯加载失败=>', error)
uni.showToast({ title: '加载失败,请稍后重试', icon: 'none' })
} finally {
loading.value = false
}
}
const getArticleSummary = (item: any) => {
return String(item?.desc || item?.content || '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
const getArticleAuthor = (item: any) => {
return String(item?.author || 'SB 体育头条').replace(/@懂球帝/g, '').trim() || 'SB 体育头条'
}
const getArticleCategoryName = (item: any) => {
return articleChannels.value.find((channel) => String(channel.id) === String(item?.cid))?.name || '体育'
}
const formatRelativeTime = (time: string) => {
if (!time) return ''
const date = new Date(String(time).replace(/-/g, '/'))
const delta = Date.now() - date.getTime()
if (Number.isNaN(date.getTime()) || delta < 0) return String(time)
const minute = 60 * 1000
if (delta < minute) return '刚刚'
if (delta < 60 * minute) return `${Math.floor(delta / minute)}分钟前`
if (delta < 24 * 60 * minute) return `${Math.floor(delta / (60 * minute))}小时前`
if (delta < 7 * 24 * 60 * minute) return `${Math.floor(delta / (24 * 60 * minute))}天前`
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
const formatFocusMeta = (match: any) => {
const date = getLocalMatchDate(match?.match_time, match)
const time = date
? `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
: '待定'
return `${match?.round_name || '赛程'} ${time}`
}
const getTeamInitial = (name: string) => String(name || '?').trim().slice(0, 1)
const loadMore = () => fetchFeedList()
const goSearch = () => uni.navigateTo({ url: '/pages/search/search' })
const goChannelManage = () => uni.navigateTo({ url: '/pages/channel/channel-manage' })
const goAllMatches = () => uni.switchTab({ url: '/packages_match/pages/worldcup' })
const goMatch = (id: number | string) => {
if (id) uni.navigateTo({ url: `/pages/match_detail/match_detail?id=${id}` })
}
const handleRedirect = async (options: any) => {
let slc = options?.slc || ''
// #ifdef H5
if (!slc) {
const urlParams = new URLSearchParams(window.location.search)
slc = urlParams.get('slc') || ''
}
// #endif
if (!slc) return
try {
const res = await resolveShortLink({
code: slc,
fingerprint: generateFingerprint(),
referer: typeof document === 'undefined' ? '' : document.referrer || ''
})
if (res.params?.invite_code) uni.setStorageSync('invite_code', res.params.invite_code)
if (res.path && res.page_type !== 'invite') {
setTimeout(() => uni.navigateTo({ url: res.path }), 300)
}
} catch (error) {
console.error('短链接解析失败', error)
}
}
const generateFingerprint = (): string => {
// #ifdef H5
try {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (context) {
context.textBaseline = 'top'
context.font = '14px Arial'
context.fillText('fp', 2, 2)
}
const raw = `${navigator.userAgent}${navigator.language}${screen.width}x${screen.height}${screen.colorDepth}${new Date().getTimezoneOffset()}${canvas.toDataURL()}`
let hash = 0
for (let index = 0; index < raw.length; index++) {
hash = (hash << 5) - hash + raw.charCodeAt(index)
hash |= 0
}
return Math.abs(hash).toString(16)
} catch {
return ''
}
// #endif
// #ifndef H5
return ''
// #endif
}
let channelsLoaded = false
onLoad(async (options: any) => {
handleRedirect(options)
await Promise.all([loadChannels(), loadFocusMatches()])
fetchFeedList()
})
onShow(async () => {
syncAllChannels()
if (!channelsLoaded && articleChannels.value.length === 0) {
await loadChannels()
fetchFeedList()
}
if (channelsLoaded && allChannels.value[currentChannel.value]?.specialType === 'mychannel') {
reloadFeed()
}
channelsLoaded = true
})
const onChannelChanged = (data: any) => {
if (data.channels) {
articleChannels.value = data.channels
syncAllChannels()
}
if (data.selectIndex >= 0) {
const selectedChannel = articleChannels.value[data.selectIndex]
const selectedIndex = findChannelIndexById(selectedChannel?.id)
switchChannel(selectedIndex >= 0 ? selectedIndex : 0)
} else {
reloadFeed()
}
}
uni.$on('channelChanged', onChannelChanged)
onUnload(() => uni.$off('channelChanged', onChannelChanged))
</script>
<style lang="scss" scoped>
.index {
height: 100vh;
background: #f6f7f9;
overflow: hidden;
display: flex;
flex-direction: column;
}
.top-bar {
flex-shrink: 0;
z-index: 10;
background: #fff;
&__content {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 24rpx 14rpx;
}
&__logo {
width: 76rpx;
height: 76rpx;
flex-shrink: 0;
&-img {
width: 100%;
height: 100%;
border-radius: 18rpx;
}
}
&__search {
flex: 1;
min-width: 0;
height: 72rpx;
padding: 0 24rpx;
gap: 14rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
background: #f3f4f6;
&-text {
color: #8c95a5;
font-size: 27rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.channel-bar {
height: 86rpx;
display: flex;
align-items: stretch;
flex-shrink: 0;
background: #fff;
border-bottom: 1rpx solid #edf0f5;
&__scroll {
flex: 1;
min-width: 0;
height: 86rpx;
white-space: nowrap;
}
&__list {
display: inline-flex;
align-items: stretch;
min-width: 100%;
height: 100%;
padding: 0 12rpx;
gap: 12rpx;
box-sizing: border-box;
white-space: nowrap;
}
&__item {
flex: 0 0 116rpx;
width: 116rpx;
position: relative;
display: flex;
justify-content: center;
align-items: center;
color: #252b36;
font-size: 29rpx;
white-space: nowrap;
overflow: hidden;
text {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
&--active {
color: #1468f5;
font-weight: 700;
&::after {
content: '';
position: absolute;
left: 50%;
bottom: 0;
width: 48rpx;
height: 6rpx;
border-radius: 999rpx;
background: #1468f5;
transform: translateX(-50%);
}
}
}
&__more {
width: 104rpx;
display: flex;
justify-content: center;
align-items: center;
gap: 2rpx;
flex-shrink: 0;
color: #252b36;
font-size: 28rpx;
background: #fff;
}
}
.feed-wrapper {
flex: 1;
min-height: 0;
background: #f6f7f9;
}
.home-content {
padding: 16rpx 24rpx 42rpx;
}
.focus-section,
.latest-section {
background: #fff;
border-radius: 22rpx;
}
.focus-section {
padding: 20rpx;
box-shadow: 0 8rpx 30rpx rgba(29, 45, 78, 0.04);
&__list {
display: flex;
gap: 14rpx;
}
}
.section-heading {
height: 54rpx;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14rpx;
&__title {
color: #151b26;
font-size: 34rpx;
font-weight: 700;
line-height: 1.2;
}
&__action {
display: flex;
align-items: center;
gap: 2rpx;
color: #1468f5;
font-size: 25rpx;
}
}
.focus-match {
flex: 1;
min-width: 0;
padding: 16rpx 14rpx 18rpx;
border: 1rpx solid #e7eaf0;
border-radius: 16rpx;
background: #fff;
&__head {
display: flex;
align-items: center;
gap: 10rpx;
min-width: 0;
}
&__league {
max-width: 92rpx;
padding: 4rpx 10rpx;
border-radius: 8rpx;
color: #235bde;
background: #eaf0ff;
font-size: 21rpx;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&--warm {
color: #ef6321;
background: #fff0e8;
}
}
&__time {
min-width: 0;
flex: 1;
color: #7d8797;
font-size: 21rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__teams {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 8rpx;
margin-top: 16rpx;
}
&__team {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
&__badge {
width: 58rpx;
height: 58rpx;
&--fallback {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #edf2ff;
color: #1468f5;
font-size: 28rpx;
font-weight: 700;
}
}
&__team-name {
width: 100%;
color: #202633;
font-size: 23rpx;
line-height: 1.25;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__versus {
color: #161d29;
font-size: 28rpx;
font-weight: 700;
}
}
.featured-card {
position: relative;
display: block;
height: 400rpx;
margin-top: 18rpx;
overflow: hidden;
border-radius: 22rpx;
background: linear-gradient(135deg, #164b91 0%, #142839 100%);
box-shadow: 0 10rpx 30rpx rgba(20, 37, 65, 0.1);
&__image,
&__shade {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
&__shade {
background: linear-gradient(180deg, rgba(8, 18, 29, 0.08) 20%, rgba(7, 15, 24, 0.82) 100%);
}
&__content {
position: relative;
z-index: 1;
height: 100%;
box-sizing: border-box;
padding: 24rpx;
display: flex;
flex-direction: column;
}
&__tag {
align-self: flex-start;
padding: 6rpx 18rpx;
border-radius: 10rpx;
color: #fff;
background: #1468f5;
font-size: 24rpx;
line-height: 1.25;
}
&__copy {
margin-top: auto;
}
&__title {
display: -webkit-box;
overflow: hidden;
color: #fff;
font-size: 36rpx;
font-weight: 700;
line-height: 1.32;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__desc {
display: -webkit-box;
overflow: hidden;
margin-top: 10rpx;
color: rgba(255, 255, 255, 0.92);
font-size: 25rpx;
line-height: 1.5;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__meta {
display: flex;
align-items: center;
gap: 28rpx;
margin-top: 16rpx;
color: rgba(255, 255, 255, 0.88);
font-size: 23rpx;
line-height: 1.2;
}
}
.latest-section {
margin-top: 18rpx;
overflow: hidden;
&__heading {
padding: 20rpx 20rpx 8rpx;
margin-bottom: 0;
}
&__filter {
display: flex;
align-items: center;
gap: 6rpx;
color: #7a8494;
font-size: 24rpx;
}
}
.feed-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 30rpx 0;
&__text {
color: #98a2b3;
font-size: 24rpx;
}
}
.feed-bottom,
.feed-empty {
color: #98a2b3;
font-size: 24rpx;
text-align: center;
}
.feed-bottom {
padding: 30rpx 0;
}
.feed-empty {
padding: 100rpx 0;
}
</style>