feat(uniapp): restore home page design

This commit is contained in:
hajimi
2026-07-31 18:12:46 +08:00
parent fdf8022662
commit deb6ce0e97
5 changed files with 667 additions and 258 deletions
+20
View File
@@ -13,6 +13,26 @@
## 一、已完成事项
### 89. 移动端首页设计稿还原
- 状态:代码已修改,待页面验收 - 时间:2026-07-31
完成内容:
- 首页按设计图重构为固定头部与频道栏、“今日焦点”赛程卡、主推资讯大图和“最新资讯”列表四个区域。
- 复用既有 `/api/match/lists``/api/article/lists` 接口,首页焦点赛程优先展示进行中与未开始的前两场,未新增后端接口或业务字段。
- 首屏资讯以现有文章封面生成主推卡,后续资讯统一使用双列文字/缩略图信息卡;外链图片增加 `referrerpolicy="no-referrer"`,加载失败时隐藏无效缩略图,避免展示浏览器错误占位。
- 首页滚动区改为 `flex: 1; min-height: 0` 自适应布局,移除 JS 高度计算,规避 iOS 安全区下滚动区与 TabBar 之间出现间隙的问题。
- 首页 AI 助手入口新增圆形机器人样式变体,仅首页使用,其他入口保持原有“AI 助手”按钮样式。
涉及模块:
- `uniapp/src/pages/index/index.vue`
- `uniapp/src/components/feed-card/feed-card.vue`
- `uniapp/src/components/ai-assistant-entry/ai-assistant-entry.vue`
- `docs/设计稿映射.md`
验证说明:
- 按当前任务要求,未执行 H5 构建或浏览器页面验证。
### 88. HG 赔率平台展示名统一为皇冠
- 状态:代码已修改,待构建与页面验证 - 时间:2026-07-13
+1 -1
View File
@@ -25,7 +25,7 @@
| 赛事中心(含世界杯专题入口) | `uniapp/src/pages/match/match.vue` | ✅ 已完成 | 2026-07-11 赛程、直播、赔率改为在赛事中心当前内容区域切换;运动和联赛分类仅在赛程显示,赔率只保留盘口与联赛/球队搜索,顶部蓝色世界杯专题卡仍进入专题页 |
| 赛事详情页 | `uniapp/src/pages/match_detail/match_detail.vue` | 🚧 部分完成 | 2026-05-29 优化顶部赛事摘要头部,补充联赛、对阵、状态与直播入口信息 |
| 世界杯专题页(排名导航) | `uniapp/src/packages_match/pages/worldcup.vue` | 🚧 部分完成 | 2026-05-20 已按设计稿拆分排名导航、积分榜、球员榜组件,后续可继续细化表格视觉与新闻页样式 |
| 首页 | `uniapp/src/pages/index/index.vue` | 待核对 | 需结合实际设计稿补充 |
| 首页 | `uniapp/src/pages/index/index.vue` | 🚧 部分完成 | 2026-07-31 已按当前设计图重构为“今日焦点赛程、主推大图、最新资讯”三段式首页;频道仍复用后台配置分类,待实际 H5 页面验收后确认最终状态 |
| 新闻详情 | `uniapp/src/pages/news_detail/news_detail.vue` | 待核对 | 富文本与图片渲染已存在相关实现 |
| 个人中心 | 待补充 | 待核对 | 需核对实际页面路径 |
| AI 分析 | 待补充 | 待核对 | 需核对实际页面路径 |
@@ -1,7 +1,18 @@
<template>
<view class="ai-entry" :class="[`ai-entry--${size}`, `ai-entry--align-${align}`]" @tap="goAssistant">
<view class="ai-entry" :class="[`ai-entry--${size}`, `ai-entry--align-${align}`, `ai-entry--${variant}`]"
@tap="goAssistant">
<template v-if="variant === 'orb'">
<view class="ai-entry__orb">
<view class="ai-entry__face">
<view class="ai-entry__eye" />
<view class="ai-entry__eye" />
</view>
</view>
</template>
<template v-else>
<text class="ai-entry__line ai-entry__line--top">AI</text>
<text class="ai-entry__line ai-entry__line--bottom">助手</text>
</template>
</view>
</template>
@@ -9,9 +20,11 @@
withDefaults(defineProps<{
align?: 'left' | 'right'
size?: 'compact' | 'regular' | 'large'
variant?: 'badge' | 'orb'
}>(), {
align: 'left',
size: 'regular'
size: 'regular',
variant: 'badge'
})
const goAssistant = () => {
@@ -60,6 +73,45 @@ const goAssistant = () => {
--entry-bottom-font: 14rpx;
}
&--orb {
--entry-width: 72rpx;
--entry-height: 72rpx;
--entry-radius: 50%;
background: #f7f9ff;
box-shadow: none;
}
&__orb {
width: 54rpx;
height: 54rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: radial-gradient(circle at 34% 28%, #8ec4ff 0%, #387df6 48%, #1557dc 100%);
box-shadow: 0 5rpx 10rpx rgba(20, 104, 245, 0.2);
}
&__face {
width: 37rpx;
height: 25rpx;
display: flex;
align-items: center;
justify-content: space-around;
padding: 0 6rpx;
border-radius: 14rpx;
background: rgba(255, 255, 255, 0.94);
box-sizing: border-box;
}
&__eye {
width: 6rpx;
height: 11rpx;
border-radius: 999rpx;
background: #1f61e8;
}
&__line {
color: #fff;
font-weight: 700;
+88 -62
View File
@@ -2,25 +2,25 @@
<navigator :url="`/pages/news_detail/news_detail?id=${item.id}`" class="feed-card">
<view class="feed-card__body">
<view class="feed-card__info">
<view class="feed-card__title">{{ item.title }}</view>
<view v-if="descText" class="feed-card__desc">{{ descText }}</view>
<text class="feed-card__title">{{ item.title }}</text>
<text v-if="descText" class="feed-card__desc">{{ descText }}</text>
<view class="feed-card__footer">
<text class="feed-card__author">{{ authorText }}</text>
<view class="feed-card__footer-right">
<text class="feed-card__meta">{{ item.comment_count || item.click || 0 }}阅读</text>
<text class="feed-card__meta">{{ formatTime(item.create_time) }}</text>
<text class="feed-card__time">{{ formatRelativeTime(item.create_time) }}</text>
<view class="feed-card__comments">
<view class="feed-card__comment-icon" />
<text>{{ item.comment_count || item.click || 0 }}</text>
</view>
</view>
</view>
<UImage v-if="item.image" :src="item.image" width="220" height="140" border-radius="8" mode="aspectFill"
class="feed-card__img" />
<image v-if="item.image && !imageFailed" :src="item.image" mode="aspectFill" class="feed-card__img"
referrerpolicy="no-referrer" @error="imageFailed = true" />
</view>
</navigator>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import UImage from '@/uni_modules/vk-uview-ui/components/u-image/u-image.vue'
import { computed, ref } from 'vue'
const props = withDefaults(
defineProps<{
@@ -31,119 +31,145 @@ const props = withDefaults(
}
)
const imageFailed = ref(false)
const stripHtml = (html: string) => {
if (!html) return ''
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
}
const descText = computed(() => {
if (props.item.desc) return props.item.desc
if (props.item.content) return stripHtml(props.item.content).substring(0, 100)
return ''
return stripHtml(props.item.desc || props.item.content || '').slice(0, 72)
})
const authorText = computed(() => {
const rawAuthor = String(props.item.author || '')
const cleanedAuthor = rawAuthor.replace(/@懂球帝/g, '').trim()
return cleanedAuthor || '体育小编'
return cleanedAuthor || 'SB 体育头条'
})
const formatTime = (t: string) => {
if (!t) return ''
const d = new Date(t.replace(/-/g, '/'))
if (isNaN(d.getTime())) return t
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${m}-${day} ${h}:${min}`
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')}`
}
</script>
<style lang="scss" scoped>
.feed-card {
display: block;
padding: 20rpx 20rpx;
padding: 22rpx 20rpx;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
border-top: 1rpx solid #edf0f5;
&:first-child {
border-top: 0;
}
&__body {
display: flex;
align-items: stretch;
gap: 20rpx;
align-items: flex-start;
min-height: 144rpx;
}
&__info {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
}
&__title {
font-size: 28rpx;
font-weight: 600;
color: #111;
line-height: 1.45;
white-space: nowrap;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
color: #151b26;
font-size: 28rpx;
font-weight: 700;
line-height: 1.4;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__desc {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
white-space: nowrap;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 8rpx;
color: #7c8798;
font-size: 24rpx;
line-height: 1.42;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
&__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 18rpx;
min-width: 0;
margin-top: auto;
padding-top: 12rpx;
gap: 16rpx;
white-space: nowrap;
color: #909aaa;
font-size: 22rpx;
line-height: 1.2;
}
&__author,
&__time {
overflow: hidden;
min-width: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
&__author {
flex: 1;
font-size: 24rpx;
color: #999;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
max-width: 156rpx;
}
&__footer-right {
&__comments {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 12rpx;
margin-left: auto;
flex-shrink: 0;
gap: 6rpx;
min-width: 0;
margin-left: auto;
white-space: nowrap;
}
&__meta {
font-size: 24rpx;
color: #bbb;
white-space: nowrap;
flex-shrink: 0;
&__comment-icon {
position: relative;
width: 24rpx;
height: 19rpx;
box-sizing: border-box;
border: 2rpx solid #8e99a9;
border-radius: 10rpx;
&::after {
content: '';
position: absolute;
right: 1rpx;
bottom: -5rpx;
width: 7rpx;
height: 7rpx;
box-sizing: border-box;
border-right: 2rpx solid #8e99a9;
border-bottom: 2rpx solid #8e99a9;
background: #fff;
transform: rotate(28deg);
}
}
&__img {
width: 216rpx;
height: 144rpx;
flex-shrink: 0;
border-radius: 12rpx;
background: #eef1f5;
}
}
</style>
+493 -182
View File
@@ -1,60 +1,122 @@
<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="28" color="#999" />
<text class="top-bar__search-text">{{ searchPlaceholder }}</text>
</view>
<view class="top-bar__actions">
<AiAssistantEntry />
<UIcon name="search" size="30" color="#8C95A5" />
<text class="top-bar__search-text">搜索赛事球队球员</text>
</view>
<AiAssistantEntry size="compact" variant="orb" />
</view>
</view>
<!-- Feed流内容 -->
<scroll-view scroll-y class="feed-wrapper" :style="{ height: feedHeight + 'px' }" @scrolltolower="loadMore"
:lower-threshold="100">
<!-- 频道Tab栏 -->
<view class="channel-bar">
<scroll-view scroll-x class="channel-bar__scroll" :scroll-left="channelScrollLeft"
scroll-with-animation>
<view class="channel-bar__list">
<view v-for="(item, index) in allChannels" :key="item.channelKey" class="channel-bar__item"
:class="{ 'channel-bar__item--active': currentChannel === index }"
@tap="switchChannel(index)">
<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">
<UIcon name="list" size="38" color="#666" />
<text>更多</text>
<UIcon name="arrow-down" size="22" color="#667085" />
</view>
</view>
<template v-for="item in feedList" :key="item.id">
<FeedTranslateCard v-if="hasTranslatedContent(item)" :item="item" />
<FeedVideoCard v-else-if="item.is_video" :item="item" />
<FeedCard v-else :item="item" />
</template>
<view class="feed-loading" v-if="loading">
<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 class="feed-bottom" v-if="noMore && feedList.length">
<view v-if="noMore && feedList.length" class="feed-bottom">
<text>没有更多了</text>
</view>
<view class="feed-empty" v-if="!loading && !feedList.length">
<view v-if="!loading && !feedList.length" class="feed-empty">
<text>暂无内容</text>
</view>
</view>
</scroll-view>
<!-- #ifdef MP -->
<MpPrivacyPopup></MpPrivacyPopup>
<MpPrivacyPopup />
<!-- #endif -->
<GlobalPopup />
@@ -63,14 +125,14 @@
<script setup lang="ts">
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { ref, nextTick } from 'vue'
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 FeedTranslateCard from '@/components/feed-translate-card/feed-translate-card.vue'
import FeedVideoCard from '@/components/feed-video-card/feed-video-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'
@@ -81,33 +143,13 @@ import MpPrivacyPopup from './component/mp-privacy-popup.vue'
// #endif
const statusBarHeight = ref(0)
const feedHeight = ref(500)
let windowHeight = 667
uni.getSystemInfo({
success: (res) => {
// #ifdef APP-PLUS || MP
statusBarHeight.value = res.statusBarHeight || 44
// #endif
// #ifdef H5
statusBarHeight.value = 0
// #endif
windowHeight = res.windowHeight || 667
const topBarH = statusBarHeight.value + 44
const tabbarH = uni.upx2px(100)
feedHeight.value = windowHeight - topBarH - tabbarH
}
})
const calcFeedHeight = () => {
const query = uni.createSelectorQuery()
query.select('.top-bar').boundingClientRect()
query.exec((rects: any[]) => {
const topBarH = rects[0]?.height || (statusBarHeight.value + 44)
const tabbarH = uni.upx2px(100)
feedHeight.value = windowHeight - topBarH - tabbarH
})
}
const searchPlaceholder = ref('搜索世博头条')
const RECOMMEND_CHANNEL = {
id: '__recommend__',
@@ -126,13 +168,30 @@ const MY_CHANNEL = {
const articleChannels = ref<any[]>([])
const allChannels = ref<any[]>([])
const currentChannel = ref(0)
const channelScrollLeft = 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) {
@@ -161,16 +220,26 @@ const findChannelIndexById = (id: any) => {
const loadChannels = async () => {
try {
const allCate = await getArticleCate()
articleChannels.value = allCate || []
syncAllChannels()
} catch (e) {
console.log('获取分类失败=>', e)
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
@@ -181,6 +250,7 @@ const reloadFeed = () => {
pageNo.value = 1
noMore.value = false
feedList.value = []
featuredImageFailed.value = false
fetchFeedList()
}
@@ -204,6 +274,7 @@ const fetchFeedList = async () => {
const newList = lists || []
if (pageNo.value === 1) {
feedList.value = newList
featuredImageFailed.value = false
} else {
feedList.value = [...feedList.value, ...newList]
}
@@ -211,43 +282,63 @@ const fetchFeedList = async () => {
noMore.value = true
}
pageNo.value++
} catch (e) {
console.log('feed加载失败=>', e)
} catch (error) {
console.log('首页资讯加载失败=>', error)
uni.showToast({ title: '加载失败,请稍后重试', icon: 'none' })
} finally {
loading.value = false
}
}
const loadMore = () => {
fetchFeedList()
const getArticleSummary = (item: any) => {
return String(item?.desc || item?.content || '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
const hasTranslatedContent = (item: any) => {
return !!String(item?.translated_content || item?.worldcup_translated_content || '').trim()
const getArticleAuthor = (item: any) => {
return String(item?.author || 'SB 体育头条').replace(/@懂球帝/g, '').trim() || 'SB 体育头条'
}
const goSearch = () => {
uni.navigateTo({ url: '/pages/search/search' })
const getArticleCategoryName = (item: any) => {
return articleChannels.value.find((channel) => String(channel.id) === String(item?.cid))?.name || '体育'
}
const goChannelManage = () => {
uni.navigateTo({ url: '/pages/channel/channel-manage' })
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')}`
}
onLoad(async (options: any) => {
//
handleRedirect(options)
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}`
}
await loadChannels()
fetchFeedList()
nextTick(() => {
setTimeout(calcFeedHeight, 100)
})
})
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) => {
// onLoad options URL query slc
let slc = options?.slc || ''
// #ifdef H5
if (!slc) {
@@ -258,60 +349,55 @@ const handleRedirect = async (options: any) => {
if (!slc) return
try {
const fingerprint = generateFingerprint()
const res = await resolveShortLink({
code: slc,
fingerprint,
referer: document?.referrer || ''
fingerprint: generateFingerprint(),
referer: typeof document === 'undefined' ? '' : document.referrer || ''
})
//
if (res.params?.invite_code) {
uni.setStorageSync('invite_code', res.params.invite_code)
}
//
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)
setTimeout(() => uni.navigateTo({ url: res.path }), 300)
}
} catch (e) {
console.error('短链接解析失败', e)
} catch (error) {
console.error('短链接解析失败', error)
}
}
const generateFingerprint = (): string => {
// #ifdef H5
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.textBaseline = 'top'
ctx.font = '14px Arial'
ctx.fillText('fp', 2, 2)
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()
const raw = `${navigator.userAgent}${navigator.language}${screen.width}x${screen.height}${screen.colorDepth}${new Date().getTimezoneOffset()}${canvas.toDataURL()}`
let hash = 0
for (let i = 0; i < raw.length; i++) {
hash = (hash << 5) - hash + raw.charCodeAt(i)
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()
// iOS
if (!channelsLoaded && articleChannels.value.length === 0) {
await loadChannels()
fetchFeedList()
@@ -335,167 +421,392 @@ const onChannelChanged = (data: any) => {
reloadFeed()
}
}
uni.$on('channelChanged', onChannelChanged)
onUnload(() => {
uni.$off('channelChanged', onChannelChanged)
})
onUnload(() => uni.$off('channelChanged', onChannelChanged))
</script>
<style lang="scss" scoped>
.index {
height: 100vh;
background: #f5f5f5;
background: #f6f7f9;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 顶部导航栏 */
.top-bar {
flex-shrink: 0;
z-index: 100;
z-index: 10;
background: #fff;
&__content {
display: flex;
align-items: center;
padding: 12rpx 24rpx;
gap: 16rpx;
padding: 16rpx 24rpx 14rpx;
}
&__logo {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
width: 76rpx;
height: 76rpx;
flex-shrink: 0;
&-img {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
width: 100%;
height: 100%;
border-radius: 18rpx;
}
}
&__search {
flex: 1;
height: 64rpx;
background: #f5f5f5;
border-radius: 16rpx;
min-width: 0;
height: 72rpx;
padding: 0 24rpx;
gap: 14rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
padding: 0 24rpx;
gap: 12rpx;
background: #f3f4f6;
&-text {
font-size: 26rpx;
color: #999;
}
}
&__actions {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
}
}
/* 频道Tab栏 */
.channel-bar {
display: flex;
flex-direction: row;
align-items: center;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
height: 80rpx;
color: #8c95a5;
font-size: 27rpx;
white-space: nowrap;
overflow: hidden;
flex-shrink: 0;
&__scroll {
flex: 1;
width: 0;
height: 80rpx;
text-overflow: ellipsis;
}
}
}
.channel-bar {
height: 86rpx;
display: flex;
align-items: stretch;
flex-shrink: 0;
background: #fff;
border-bottom: 1rpx solid #edf0f5;
&__list {
white-space: nowrap;
height: 80rpx;
padding: 0 8rpx;
min-width: 0;
flex: 1;
display: flex;
align-items: stretch;
}
&__item {
display: inline-block;
padding: 0 16rpx;
height: 80rpx;
line-height: 80rpx;
font-size: 28rpx;
color: #666;
flex: 1;
min-width: 0;
position: relative;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
color: #252b36;
font-size: 29rpx;
white-space: nowrap;
overflow: hidden;
text {
overflow: hidden;
text-overflow: ellipsis;
}
&--active {
color: #0053d8;
font-weight: bold;
color: #1468f5;
font-weight: 700;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
bottom: 0;
width: 48rpx;
height: 6rpx;
background: #0053d8;
border-radius: 3rpx;
border-radius: 999rpx;
background: #1468f5;
transform: translateX(-50%);
}
}
}
&__more {
width: 80rpx;
height: 80rpx;
width: 104rpx;
display: flex;
align-items: center;
justify-content: center;
border-left: 1rpx solid #f0f0f0;
align-items: center;
gap: 2rpx;
flex-shrink: 0;
color: #252b36;
font-size: 28rpx;
background: #fff;
}
}
/* Feed流 */
.feed-wrapper {
flex: 1;
min-height: 0;
background: #f5f5f5;
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;
padding: 30rpx 0;
gap: 12rpx;
padding: 30rpx 0;
&__text {
color: #98a2b3;
font-size: 24rpx;
color: #999;
}
}
.feed-bottom,
.feed-empty {
color: #98a2b3;
font-size: 24rpx;
text-align: center;
}
.feed-bottom {
text-align: center;
padding: 30rpx 0;
font-size: 24rpx;
color: #ccc;
}
.feed-empty {
text-align: center;
padding: 100rpx 0;
font-size: 28rpx;
color: #999;
}
</style>