From 5f2e2e6c0094e7cb1d06249acec310ad00922c26 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 12:46:53 +0800 Subject: [PATCH] feat: add uniapp match live stream switching --- qa/frontend/test_match_detail_live_static.py | 31 ++ uniapp/src/api/match.ts | 34 ++ .../match-live-popup/match-live-popup.vue | 329 ++++++++++++++++++ .../src/pages/match_detail/match_detail.vue | 103 +++++- 4 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 qa/frontend/test_match_detail_live_static.py create mode 100644 uniapp/src/components/match-live-popup/match-live-popup.vue diff --git a/qa/frontend/test_match_detail_live_static.py b/qa/frontend/test_match_detail_live_static.py new file mode 100644 index 0000000..d9c335b --- /dev/null +++ b/qa/frontend/test_match_detail_live_static.py @@ -0,0 +1,31 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MATCH_API = ROOT / "uniapp" / "src" / "api" / "match.ts" +MATCH_DETAIL = ROOT / "uniapp" / "src" / "pages" / "match_detail" / "match_detail.vue" +MATCH_LIVE_POPUP = ROOT / "uniapp" / "src" / "components" / "match-live-popup" / "match-live-popup.vue" + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def main() -> None: + assert MATCH_LIVE_POPUP.exists(), f"missing popup component: {MATCH_LIVE_POPUP}" + + detail_source = read_text(MATCH_DETAIL) + popup_source = read_text(MATCH_LIVE_POPUP) + api_source = read_text(MATCH_API) + + assert "live_list" in detail_source, "match detail should handle live_list" + assert "MatchLivePopup" in detail_source, "match detail should use MatchLivePopup" + assert "play_url_m3u8" in popup_source, "popup should support play_url_m3u8" + assert "stream_options" in popup_source, "popup should support stream_options" + assert "/pages/webview/webview?url=" in popup_source, "selected stream should route through existing webview page" + + assert "/match/detail" in api_source, "match detail API path should stay on /match/detail" + + +if __name__ == "__main__": + main() diff --git a/uniapp/src/api/match.ts b/uniapp/src/api/match.ts index f883994..b2acc4b 100644 --- a/uniapp/src/api/match.ts +++ b/uniapp/src/api/match.ts @@ -1,5 +1,39 @@ import request from '@/utils/request' +export interface MatchLiveStreamOption { + title?: string + name?: string + label?: string + source_name?: string + quality?: string + url?: string + play_url?: string + play_url_m3u8?: string + default_play_url?: string + live_url?: string + [key: string]: any +} + +export interface MatchLiveLineItem { + title?: string + name?: string + label?: string + live_tag?: string + source_name?: string + status?: string | number + status_text?: string + status_desc?: string + update_time?: string | number + updated_at?: string | number + source_url?: string + default_play_url?: string + play_url_m3u8?: string + play_url?: string + live_url?: string + stream_options?: MatchLiveStreamOption[] + [key: string]: any +} + export function getMatchList(data?: Record) { return request.get({ url: '/match/lists', data: data }, { withToken: false }) } diff --git a/uniapp/src/components/match-live-popup/match-live-popup.vue b/uniapp/src/components/match-live-popup/match-live-popup.vue new file mode 100644 index 0000000..4843cae --- /dev/null +++ b/uniapp/src/components/match-live-popup/match-live-popup.vue @@ -0,0 +1,329 @@ + + + + + diff --git a/uniapp/src/pages/match_detail/match_detail.vue b/uniapp/src/pages/match_detail/match_detail.vue index 7dbaaaf..457fea7 100644 --- a/uniapp/src/pages/match_detail/match_detail.vue +++ b/uniapp/src/pages/match_detail/match_detail.vue @@ -62,8 +62,31 @@ + + + 直播线路 + 选择线路后通过内置 WebView 打开 + + + + + {{ index + 1 }} + + + {{ getLiveLineTitle(item) }} + {{ getLiveLineMeta(item) }} + + + + 切换线路 + + + + + - + @@ -412,6 +435,8 @@ + + import { ref, computed, nextTick, onMounted } 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' 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' @@ -447,9 +474,14 @@ const aiInlineLoading = ref(false) const aiInline = ref({}) const showSharePopup = ref(false) const liveTextList = ref([]) +const showLivePopup = ref(false) +const selectedLiveLine = ref(null) let liveTextTimer: ReturnType | null = null const thirdPartyLiveUrl = computed(() => String(match.value?.live_url || '').trim()) +const liveList = computed(() => { + return Array.isArray(match.value?.live_list) ? match.value.live_list : [] +}) const liveSourceText = computed(() => { const raw = match.value?.living_tv ?? match.value?.live_source ?? match.value?.livingTv ?? '' @@ -480,6 +512,41 @@ const homeSubs = computed(() => lineupList.value.filter((p: any) => p.team_side const awayStarters = computed(() => lineupList.value.filter((p: any) => p.team_side === 2 && p.is_starter === 1)) const awaySubs = computed(() => lineupList.value.filter((p: any) => p.team_side === 2 && p.is_starter === 0)) +const normalizeLiveText = (value: unknown) => String(value || '').trim() + +const getLiveLineTitle = (item: MatchLiveLineItem) => { + return ( + normalizeLiveText(item?.title) || + normalizeLiveText(item?.name) || + normalizeLiveText(item?.label) || + normalizeLiveText(item?.live_tag) || + normalizeLiveText(item?.source_name) || + '直播线路' + ) +} + +const formatLiveLineUpdate = (value: unknown) => { + const text = normalizeLiveText(value) + if (!text) return '' + const hhmmss = text.match(/(\d{2}:\d{2}:\d{2})$/) + if (hhmmss) return hhmmss[1] + const hhmm = text.match(/(\d{2}:\d{2})$/) + if (hhmm) return hhmm[1] + if (/^\d{10,13}$/.test(text)) { + const raw = text.length === 13 ? Number(text) : Number(text) * 1000 + return formatLiveTime(raw / 1000) + } + return text +} + +const getLiveLineMeta = (item: MatchLiveLineItem) => { + const parts = [ + normalizeLiveText(item?.status_text) || normalizeLiveText(item?.status_desc) || normalizeLiveText(item?.status), + formatLiveLineUpdate(item?.updated_at || item?.update_time), + ].filter(Boolean) + return parts.length ? parts.join(' · ') : '点击选择直播线路' +} + const techStatsList = computed(() => { const m = match.value as any // 优先使用 tech_stats JSON(兼容所有运动类型) @@ -540,6 +607,8 @@ onMounted(() => { const fetchDetail = async (id: number) => { loading.value = true + showLivePopup.value = false + selectedLiveLine.value = null try { const data = await getMatchDetail({ id }) match.value = data || {} @@ -604,6 +673,15 @@ const openThirdPartyLive = () => { uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` }) } +const openLiveLine = (item: MatchLiveLineItem) => { + selectedLiveLine.value = item + showLivePopup.value = true +} + +const handleLivePopupClose = () => { + showLivePopup.value = false +} + const formatLiveTime = (ts: any) => { if (!ts) return '' const s = String(ts) @@ -1560,6 +1638,29 @@ onUnload(() => { .live-entrance { margin: 0 24rpx 24rpx; + display: flex; + flex-direction: column; + gap: 16rpx; + + &__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16rpx; + padding: 0 4rpx; + } + + &__heading { + font-size: 30rpx; + font-weight: 700; + color: #111827; + } + + &__subheading { + font-size: 22rpx; + color: #6b7280; + text-align: right; + } &__card { display: flex;