deploy: auto commit repo-root changes 2026-06-21 17:56:21
This commit is contained in:
@@ -1,12 +1,7 @@
|
||||
<template>
|
||||
<view class="match-detail">
|
||||
<match-detail-header
|
||||
:status-bar-height="statusBarHeight"
|
||||
title="赛事详情"
|
||||
:subtitle="match.league_name || '赛事信息加载中'"
|
||||
@back="goBack"
|
||||
@share="showSharePopup = true"
|
||||
/>
|
||||
<match-detail-header :status-bar-height="statusBarHeight" title="赛事详情"
|
||||
:subtitle="match.league_name || '赛事信息加载中'" @back="goBack" @share="showSharePopup = true" />
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view class="detail-loading" v-if="loading">
|
||||
@@ -63,31 +58,66 @@
|
||||
</view>
|
||||
|
||||
<view v-if="liveList.length" class="live-entrance">
|
||||
<view class="live-entrance__header">
|
||||
<text class="live-entrance__heading">直播线路</text>
|
||||
<text class="live-entrance__subheading">选择线路后通过内置 WebView 打开</text>
|
||||
</view>
|
||||
<view v-for="(item, index) in liveList" :key="item.id || item.title || item.name || index"
|
||||
class="live-entrance__card" @tap="openLiveLine(item)">
|
||||
<view class="live-entrance__left">
|
||||
<view class="live-entrance__badge">
|
||||
<text>{{ index + 1 }}</text>
|
||||
<view class="live-entrance__player-card">
|
||||
<view class="live-entrance__player-header">
|
||||
<view class="live-entrance__player-title-wrap">
|
||||
<text class="live-entrance__player-title">{{ activeLiveLineTitle }}</text>
|
||||
</view>
|
||||
<view class="live-entrance__content">
|
||||
<text class="live-entrance__title">{{ getLiveLineTitle(item) }}</text>
|
||||
<text class="live-entrance__desc">{{ getLiveLineMeta(item) }}</text>
|
||||
<view class="live-entrance__player-actions">
|
||||
<u-button v-if="activeLivePlayUrl" size="mini" shape="circle" plain
|
||||
@click.stop="openLiveFullscreen">
|
||||
全屏播放
|
||||
</u-button>
|
||||
<u-button size="mini" shape="circle" plain @click.stop="toggleLiveLineSelector">
|
||||
{{ showLiveLineSelector ? '收起线路' : '切换线路' }}
|
||||
</u-button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="live-entrance__action">
|
||||
<text>切换线路</text>
|
||||
<u-icon name="arrow-right" size="26" color="#c7d2fe"></u-icon>
|
||||
|
||||
<view v-if="activeLivePlayUrl" class="live-entrance__player-wrap">
|
||||
<video id="match-live-player" :key="activeLivePlayerKey" class="live-entrance__player"
|
||||
:src="activeLivePlayUrl" :poster="match.home_icon || match.away_icon || ''" :autoplay="true"
|
||||
:controls="true" :show-play-btn="true" :show-center-play-btn="true"
|
||||
:show-fullscreen-btn="true" :page-gesture="true" object-fit="contain"
|
||||
@play="handleLivePlayerPlay" @loadedmetadata="handleLivePlayerLoaded"
|
||||
@waiting="handleLivePlayerWaiting" @error="handleLivePlayerError" />
|
||||
</view>
|
||||
<view v-else class="live-entrance__player-empty">
|
||||
<text class="live-entrance__player-empty-title">当前线路暂无可用直播放流</text>
|
||||
<text class="live-entrance__player-empty-desc">可切换其他线路,或打开当前线路源站继续观看</text>
|
||||
<u-button v-if="activeLiveSourceUrl" size="mini" shape="circle" plain
|
||||
class="live-entrance__empty-action" @click.stop="openActiveLiveSource">
|
||||
打开当前线路源站
|
||||
</u-button>
|
||||
</view>
|
||||
|
||||
<text v-if="activeLiveError" class="live-entrance__player-error">{{ activeLiveError }}</text>
|
||||
|
||||
<view v-if="showLiveLineSelector" class="live-entrance__selector">
|
||||
<view v-for="(item, index) in resolvedLiveList" :key="item.lineKey"
|
||||
class="live-entrance__selector-item"
|
||||
:class="{ 'live-entrance__selector-item--active': item.lineKey === activeLiveLineKey }"
|
||||
@tap="selectLiveLine(item)">
|
||||
<view class="live-entrance__selector-main">
|
||||
<view class="live-entrance__selector-index">
|
||||
<text>{{ index + 1 }}</text>
|
||||
</view>
|
||||
<view class="live-entrance__selector-content">
|
||||
<text class="live-entrance__selector-title">{{ item.displayTitle }}</text>
|
||||
<text class="live-entrance__selector-desc">{{ item.metaText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="live-entrance__selector-tag">
|
||||
{{ item.lineKey === activeLiveLineKey ? '当前播放' : item.hasDirectPlay ? '点击切换' : '源站兜底' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tab 导航栏 -->
|
||||
<view v-else-if="thirdPartyLiveUrl" class="live-entrance">
|
||||
<view class="live-entrance__card" @tap="openThirdPartyLive">
|
||||
<view class="live-entrance__fallback-card" @tap="openThirdPartyLive">
|
||||
<view class="live-entrance__left">
|
||||
<view class="live-entrance__badge">
|
||||
<text>直播</text>
|
||||
@@ -435,8 +465,6 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<MatchLivePopup v-model:show="showLivePopup" :line="selectedLiveLine" @close="handleLivePopupClose" />
|
||||
|
||||
<!-- 分享弹窗 -->
|
||||
<SharePopup v-if="showSharePopup" v-model:show="showSharePopup" page-type="match"
|
||||
:path="`/pages/match_detail/match_detail?id=${match.id || ''}`"
|
||||
@@ -446,9 +474,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||
import MatchDetailHeader from '@/components/match-detail-header/match-detail-header.vue'
|
||||
import MatchLivePopup from '@/components/match-live-popup/match-live-popup.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'
|
||||
@@ -458,6 +485,15 @@ import { checkAiUnlock, getMatchPredictSection } from '@/api/ai'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getLocalMatchDate } from '@/utils/match-time'
|
||||
|
||||
type MatchDetailLiveLine = MatchLiveLineItem & {
|
||||
lineKey: string
|
||||
displayTitle: string
|
||||
metaText: string
|
||||
playUrl: string
|
||||
sourceUrl: string
|
||||
hasDirectPlay: boolean
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
const vipInfo = computed(() => userStore.userInfo?.vip_info || {})
|
||||
const isVipUser = computed(() => !!vipInfo.value.is_vip)
|
||||
@@ -474,8 +510,10 @@ const aiInlineLoading = ref(false)
|
||||
const aiInline = ref<any>({})
|
||||
const showSharePopup = ref(false)
|
||||
const liveTextList = ref<any[]>([])
|
||||
const showLivePopup = ref(false)
|
||||
const selectedLiveLine = ref<MatchLiveLineItem | null>(null)
|
||||
const activeLiveLineKey = ref('')
|
||||
const activeLiveError = ref('')
|
||||
const livePlayerLoading = ref(false)
|
||||
const showLiveLineSelector = ref(false)
|
||||
let liveTextTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const thirdPartyLiveUrl = computed(() => String(match.value?.live_url || '').trim())
|
||||
@@ -557,6 +595,131 @@ const getLiveLineMeta = (item: MatchLiveLineItem) => {
|
||||
return parts.length ? parts.join(' · ') : '点击选择直播线路'
|
||||
}
|
||||
|
||||
const getLiveLineKey = (item: MatchLiveLineItem, index: number) => {
|
||||
return normalizeLiveText(item?.id) || `${getLiveLineTitle(item)}-${index}`
|
||||
}
|
||||
|
||||
const collectLiveCandidateUrls = (item: MatchLiveLineItem) => {
|
||||
const candidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const push = (value: unknown) => {
|
||||
const url = normalizeLiveText(value)
|
||||
if (!url || seen.has(url)) return
|
||||
seen.add(url)
|
||||
candidates.push(url)
|
||||
}
|
||||
|
||||
push(item?.default_play_url)
|
||||
if (Array.isArray(item?.stream_options)) {
|
||||
item.stream_options.forEach((option) => {
|
||||
push(option?.default_play_url)
|
||||
push(option?.play_url_m3u8)
|
||||
push(option?.play_url_hd_m3u8)
|
||||
push(option?.play_url)
|
||||
push(option?.url)
|
||||
push(option?.live_url)
|
||||
push(option?.play_url_flv)
|
||||
push(option?.play_url_hd_flv)
|
||||
})
|
||||
}
|
||||
push(item?.play_url_m3u8)
|
||||
push(item?.play_url_hd_m3u8)
|
||||
push(item?.play_url)
|
||||
push(item?.live_url)
|
||||
push(item?.play_url_flv)
|
||||
push(item?.play_url_hd_flv)
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
const isInlinePlayableUrl = (url: string) => {
|
||||
return /\.(m3u8|mp4)(\?|$)/i.test(url)
|
||||
}
|
||||
|
||||
const resolveLivePlayUrl = (item: MatchLiveLineItem) => {
|
||||
return collectLiveCandidateUrls(item).find((url) => isInlinePlayableUrl(url)) || ''
|
||||
}
|
||||
|
||||
const resolveLiveSourceUrl = (item: MatchLiveLineItem) => {
|
||||
return (
|
||||
normalizeLiveText(item?.source_url) ||
|
||||
normalizeLiveText(item?.live_url) ||
|
||||
thirdPartyLiveUrl.value
|
||||
)
|
||||
}
|
||||
|
||||
const resolvedLiveList = computed<MatchDetailLiveLine[]>(() => {
|
||||
return liveList.value.map((item, index) => {
|
||||
const playUrl = resolveLivePlayUrl(item)
|
||||
return {
|
||||
...item,
|
||||
lineKey: getLiveLineKey(item, index),
|
||||
displayTitle: getLiveLineTitle(item),
|
||||
metaText: getLiveLineMeta(item),
|
||||
playUrl,
|
||||
sourceUrl: resolveLiveSourceUrl(item),
|
||||
hasDirectPlay: !!playUrl,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const activeLiveLine = computed(() => {
|
||||
return (
|
||||
resolvedLiveList.value.find((item) => item.lineKey === activeLiveLineKey.value) ||
|
||||
resolvedLiveList.value[0] ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const activeLivePlayUrl = computed(() => activeLiveLine.value?.playUrl || '')
|
||||
const activeLiveSourceUrl = computed(() => activeLiveLine.value?.sourceUrl || thirdPartyLiveUrl.value)
|
||||
const activeLiveLineTitle = computed(() => activeLiveLine.value?.displayTitle || '直播线路')
|
||||
const activeLiveMetaText = computed(() => activeLiveLine.value?.metaText || '')
|
||||
const activeLivePlayerKey = computed(() => `${activeLiveLine.value?.lineKey || 'live'}-${activeLivePlayUrl.value}`)
|
||||
|
||||
const triggerLiveAutoPlay = () => {
|
||||
if (!activeLivePlayUrl.value) return
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const videoContext = uni.createVideoContext('match-live-player')
|
||||
videoContext?.play?.()
|
||||
} catch (error) {
|
||||
console.warn('自动播放直播失败', error)
|
||||
}
|
||||
}, 80)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
resolvedLiveList,
|
||||
(list) => {
|
||||
if (!list.length) {
|
||||
activeLiveLineKey.value = ''
|
||||
activeLiveError.value = ''
|
||||
livePlayerLoading.value = false
|
||||
showLiveLineSelector.value = false
|
||||
return
|
||||
}
|
||||
if (list.some((item) => item.lineKey === activeLiveLineKey.value)) return
|
||||
activeLiveLineKey.value = (list.find((item) => item.hasDirectPlay) || list[0]).lineKey
|
||||
activeLiveError.value = ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
activeLivePlayUrl,
|
||||
(url) => {
|
||||
activeLiveError.value = ''
|
||||
livePlayerLoading.value = !!url
|
||||
if (url) {
|
||||
triggerLiveAutoPlay()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const techStatsList = computed(() => {
|
||||
const m = match.value as any
|
||||
// 优先使用 tech_stats JSON(兼容所有运动类型)
|
||||
@@ -617,8 +780,7 @@ onMounted(() => {
|
||||
|
||||
const fetchDetail = async (id: number) => {
|
||||
loading.value = true
|
||||
showLivePopup.value = false
|
||||
selectedLiveLine.value = null
|
||||
activeLiveError.value = ''
|
||||
try {
|
||||
const data = await getMatchDetail({ id })
|
||||
match.value = data || {}
|
||||
@@ -674,8 +836,7 @@ const fetchLineup = async (matchId: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openThirdPartyLive = () => {
|
||||
const url = thirdPartyLiveUrl.value
|
||||
const navigateToLiveWebview = (url: string) => {
|
||||
if (!url) {
|
||||
uni.showToast({ title: '暂无直播链接', icon: 'none' })
|
||||
return
|
||||
@@ -683,13 +844,67 @@ const openThirdPartyLive = () => {
|
||||
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` })
|
||||
}
|
||||
|
||||
const openLiveLine = (item: MatchLiveLineItem) => {
|
||||
selectedLiveLine.value = item
|
||||
showLivePopup.value = true
|
||||
const openThirdPartyLive = () => {
|
||||
navigateToLiveWebview(thirdPartyLiveUrl.value)
|
||||
}
|
||||
|
||||
const handleLivePopupClose = () => {
|
||||
showLivePopup.value = false
|
||||
const selectLiveLine = (item: MatchDetailLiveLine) => {
|
||||
activeLiveLineKey.value = item.lineKey
|
||||
activeLiveError.value = ''
|
||||
livePlayerLoading.value = !!item.playUrl
|
||||
showLiveLineSelector.value = false
|
||||
if (item.playUrl) {
|
||||
triggerLiveAutoPlay()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLiveLineSelector = () => {
|
||||
if (!resolvedLiveList.value.length) {
|
||||
uni.showToast({ title: '暂无直播线路', icon: 'none' })
|
||||
return
|
||||
}
|
||||
showLiveLineSelector.value = !showLiveLineSelector.value
|
||||
}
|
||||
|
||||
const openActiveLiveSource = () => {
|
||||
navigateToLiveWebview(activeLiveSourceUrl.value)
|
||||
}
|
||||
|
||||
const openLiveFullscreen = () => {
|
||||
if (!activeLivePlayUrl.value) {
|
||||
uni.showToast({ title: '暂无可全屏播放的视频', icon: 'none' })
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
try {
|
||||
const videoContext = uni.createVideoContext('match-live-player')
|
||||
videoContext?.requestFullScreen?.({
|
||||
direction: 90,
|
||||
})
|
||||
videoContext?.play?.()
|
||||
} catch (error) {
|
||||
console.warn('进入全屏播放失败', error)
|
||||
uni.showToast({ title: '当前设备暂不支持全屏', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleLivePlayerError = () => {
|
||||
livePlayerLoading.value = false
|
||||
activeLiveError.value = '当前线路播放失败,可切换其他线路或打开源站继续观看'
|
||||
}
|
||||
|
||||
const handleLivePlayerWaiting = () => {
|
||||
if (!activeLivePlayUrl.value) return
|
||||
livePlayerLoading.value = true
|
||||
}
|
||||
|
||||
const handleLivePlayerLoaded = () => {
|
||||
livePlayerLoading.value = false
|
||||
}
|
||||
|
||||
const handleLivePlayerPlay = () => {
|
||||
livePlayerLoading.value = false
|
||||
}
|
||||
|
||||
const formatLiveTime = (ts: any) => {
|
||||
@@ -1652,27 +1867,193 @@ onUnload(() => {
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
|
||||
&__header {
|
||||
&__player-card {
|
||||
padding: 24rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #eef4ff 100%);
|
||||
box-shadow: 0 18rpx 40rpx rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
&__player-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 0 4rpx;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
&__player-title-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__player-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__subheading {
|
||||
&__player-meta {
|
||||
font-size: 22rpx;
|
||||
color: #6b7280;
|
||||
text-align: right;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__card {
|
||||
&__player-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__player-wrap {
|
||||
position: relative;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
&__player {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
&__player-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18rpx;
|
||||
background: rgba(2, 6, 23, 0.62);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__player-loading-text {
|
||||
font-size: 24rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
&__selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
padding-top: 4rpx;
|
||||
}
|
||||
|
||||
&__selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 22rpx 20rpx;
|
||||
border-radius: 22rpx;
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
border: 2rpx solid transparent;
|
||||
|
||||
&--active {
|
||||
background: rgba(24, 93, 255, 0.08);
|
||||
border-color: rgba(24, 93, 255, 0.28);
|
||||
}
|
||||
}
|
||||
|
||||
&__selector-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
&__selector-index {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #0f172a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__selector-content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
&__selector-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__selector-desc {
|
||||
font-size: 22rpx;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__selector-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
color: #185dff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__player-empty {
|
||||
min-height: 240rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx 32rpx;
|
||||
text-align: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
&__empty-action {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
&__player-empty-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__player-empty-desc {
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
&__player-error {
|
||||
font-size: 22rpx;
|
||||
color: #dc2626;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__fallback-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user