deploy: auto commit repo-root changes 2026-06-21 17:56:21

This commit is contained in:
hajimi
2026-06-21 17:56:21 +08:00
parent bb1a2ebb9e
commit 980b019809
5 changed files with 836 additions and 175 deletions
@@ -2,25 +2,26 @@
<u-popup v-model="showPopup" mode="bottom" safe-area-inset-bottom border-radius="24" closeable @close="handleClose">
<view class="match-live-popup">
<view class="match-live-popup__header">
<text class="match-live-popup__title">{{ lineTitle }}</text>
<text class="match-live-popup__desc">{{ lineMetaText }}</text>
<text class="match-live-popup__title">{{ popupTitle }}</text>
<text class="match-live-popup__desc">{{ popupDesc }}</text>
</view>
<view class="match-live-popup__section">
<text class="match-live-popup__section-title">直播线路</text>
<view v-if="streamOptions.length" class="match-live-popup__options">
<view v-if="lineOptions.length" class="match-live-popup__options">
<view
v-for="item in streamOptions"
v-for="item in lineOptions"
:key="item.key"
class="match-live-popup__option"
:class="{ 'match-live-popup__option--active': item.key === selectedStreamKey }"
@tap="selectOption(item)"
:class="{ 'match-live-popup__option--active': item.key === selectedLineKey }"
@tap="selectLine(item)"
>
<view class="match-live-popup__option-main">
<text class="match-live-popup__option-title">{{ item.label }}</text>
<text class="match-live-popup__option-title">{{ item.title }}</text>
<text class="match-live-popup__option-meta">{{ item.meta }}</text>
<text class="match-live-popup__option-url">{{ item.url }}</text>
</view>
<text v-if="item.key === selectedStreamKey" class="match-live-popup__option-tag">当前</text>
<text v-if="item.key === selectedLineKey" class="match-live-popup__option-tag">当前</text>
</view>
</view>
<view v-else class="match-live-popup__empty">
@@ -38,17 +39,21 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import type { MatchLiveLineItem, MatchLiveStreamOption } from '@/api/match'
import type { MatchLiveLineItem } from '@/api/match'
type PopupStreamOption = MatchLiveStreamOption & {
type PopupLiveLineOption = MatchLiveLineItem & {
key: string
label: string
title: string
meta: string
url: string
directUrl: string
sourceUrl: string
}
const props = defineProps<{
show: boolean
line: MatchLiveLineItem | null
matchTitle?: string
lines: MatchLiveLineItem[]
}>()
const emit = defineEmits(['update:show', 'close'])
@@ -58,15 +63,10 @@ const showPopup = computed({
set: (value: boolean) => emit('update:show', value),
})
const selectedStreamKey = ref('')
const selectedLineKey = ref('')
const normalizeText = (value: unknown) => String(value || '').trim()
const normalizeUrl = (value: unknown) => {
const text = normalizeText(value)
return text
}
const getFetchStatusText = (value: unknown) => {
const text = normalizeText(value)
if (!text) return ''
@@ -96,110 +96,91 @@ const formatMetaTime = (value: unknown) => {
return text
}
const lineTitle = computed(() => {
const line = props.line || {}
return (
const collectLineUrls = (line: MatchLiveLineItem) => {
const candidates: string[] = []
const seen = new Set<string>()
const push = (value: unknown) => {
const url = normalizeText(value)
if (!url || seen.has(url)) return
seen.add(url)
candidates.push(url)
}
push(line.default_play_url)
if (Array.isArray(line.stream_options)) {
line.stream_options.forEach((option) => {
push(option.default_play_url)
push(option.play_url_m3u8)
push(option.play_url_hd_m3u8)
push(option.play_url_flv)
push(option.play_url_hd_flv)
push(option.play_url)
push(option.url)
push(option.live_url)
})
}
push(line.play_url_m3u8)
push(line.play_url_hd_m3u8)
push(line.play_url_flv)
push(line.play_url_hd_flv)
push(line.play_url)
return candidates
}
const buildLineOption = (line: MatchLiveLineItem, index: number) => {
const directUrl = collectLineUrls(line)[0] || ''
const sourceUrl = normalizeText(line.source_url || line.live_url)
const url = directUrl || sourceUrl
if (!url) return null
const title = (
normalizeText(line.title) ||
normalizeText(line.name) ||
normalizeText(line.label) ||
normalizeText(line.live_tag) ||
normalizeText(line.source_name) ||
'直播线路'
`线路${index + 1}`
)
})
const lineMetaText = computed(() => {
const line = props.line || {}
const parts = [
const metaParts = [
getFetchStatusText(line.fetch_status),
formatMetaTime(line.last_fetch_at || line.update_time),
].filter(Boolean)
return parts.length ? parts.join(' · ') : '请选择可用直播线路后打开'
})
const sourceUrl = computed(() => normalizeUrl(props.line?.source_url))
const buildStreamOption = (option: MatchLiveStreamOption | undefined, index: number) => {
if (!option) return null
const url = (
[
option.default_play_url,
option.play_url_m3u8,
option.play_url_hd_m3u8,
option.play_url_flv,
option.play_url_hd_flv,
option.play_url,
option.url,
option.live_url,
].map((item) => normalizeUrl(item)).find(Boolean) || ''
)
if (!url) return null
return {
...option,
key: `${index}-${url}`,
label:
normalizeText(option.title) ||
normalizeText(option.name) ||
normalizeText(option.label) ||
normalizeText(option.source_name) ||
normalizeText(option.quality) ||
`线路${index + 1}`,
...line,
key: normalizeText(line.id) || `${title}-${index}`,
title,
meta: metaParts.length ? metaParts.join(' · ') : (directUrl ? '可直接播放' : '源站兜底'),
url,
} satisfies PopupStreamOption
directUrl,
sourceUrl,
} satisfies PopupLiveLineOption
}
const streamOptions = computed(() => {
const line = props.line || {}
const options: PopupStreamOption[] = []
const seen = new Set<string>()
const fallbackFields: Array<{ field: keyof MatchLiveLineItem; label: string }> = [
{ field: 'default_play_url', label: '默认线路' },
{ field: 'play_url_m3u8', label: 'M3U8 线路' },
{ field: 'play_url_hd_m3u8', label: '高清 M3U8' },
{ field: 'play_url_flv', label: 'FLV 线路' },
{ field: 'play_url_hd_flv', label: '高清 FLV' },
{ field: 'play_url', label: '备用线路' },
{ field: 'live_url', label: '直播地址' },
]
const pushOption = (option: MatchLiveStreamOption | undefined, index: number) => {
const built = buildStreamOption(option, index)
if (!built || seen.has(built.url)) return
seen.add(built.url)
options.push(built)
}
if (Array.isArray(line.stream_options)) {
line.stream_options.forEach((item, index) => pushOption(item, index))
}
fallbackFields.forEach(({ field, label }) => {
const value = normalizeUrl(line[field])
if (!value || seen.has(value)) return
pushOption({ label, [field]: value }, options.length)
})
return options
const lineOptions = computed(() => {
return (props.lines || [])
.map((line, index) => buildLineOption(line, index))
.filter((item): item is PopupLiveLineOption => !!item)
})
const selectedStream = computed(() => {
return streamOptions.value.find((item) => item.key === selectedStreamKey.value) || streamOptions.value[0] || null
const selectedLine = computed(() => {
return lineOptions.value.find((item) => item.key === selectedLineKey.value) || lineOptions.value[0] || null
})
const popupTitle = computed(() => normalizeText(props.matchTitle) || '切换线路')
const popupDesc = computed(() => {
if (!lineOptions.value.length) return '暂无可切换的直播线路'
return selectedLine.value?.meta || `${lineOptions.value.length} 条直播线路`
})
const resetSelected = () => {
const defaultPlayUrl = normalizeUrl(props.line?.default_play_url)
if (defaultPlayUrl) {
const matched = streamOptions.value.find((item) => item.url === defaultPlayUrl)
if (matched) {
selectedStreamKey.value = matched.key
return
}
}
selectedStreamKey.value = streamOptions.value[0]?.key || ''
selectedLineKey.value = lineOptions.value[0]?.key || ''
}
watch(
() => [props.line, props.show],
() => [props.lines, props.show],
() => {
resetSelected()
},
@@ -210,12 +191,12 @@ const navigateToWebview = (url: string) => {
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` })
}
const selectOption = (item: PopupStreamOption) => {
selectedStreamKey.value = item.key
const selectLine = (item: PopupLiveLineOption) => {
selectedLineKey.value = item.key
}
const openSelected = () => {
const url = selectedStream.value?.url || ''
const url = selectedLine.value?.directUrl || selectedLine.value?.sourceUrl || ''
if (!url) {
uni.showToast({ title: '暂无可用直播链接', icon: 'none' })
return
@@ -224,11 +205,12 @@ const openSelected = () => {
}
const openSource = () => {
if (!sourceUrl.value) {
const url = selectedLine.value?.sourceUrl || selectedLine.value?.directUrl || ''
if (!url) {
uni.showToast({ title: '暂无源站链接', icon: 'none' })
return
}
navigateToWebview(sourceUrl.value)
navigateToWebview(url)
}
const handleClose = () => {
@@ -282,15 +264,15 @@ const handleClose = () => {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 22rpx 24rpx;
border: 2rpx solid #e5e7eb;
gap: 18rpx;
padding: 20rpx 22rpx;
border-radius: 20rpx;
background: #f9fafb;
background: #f8fafc;
border: 2rpx solid transparent;
&--active {
border-color: #185dff;
background: rgba(24, 93, 255, 0.08);
border-color: #3552ff;
background: rgba(53, 82, 255, 0.08);
}
}
@@ -298,18 +280,26 @@ const handleClose = () => {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
gap: 6rpx;
flex: 1;
}
&__option-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
color: #0f172a;
line-height: 1.4;
}
&__option-meta {
font-size: 22rpx;
color: #64748b;
line-height: 1.4;
}
&__option-url {
font-size: 22rpx;
color: #6b7280;
color: #3552ff;
line-height: 1.4;
word-break: break-all;
}
@@ -318,23 +308,26 @@ const handleClose = () => {
flex-shrink: 0;
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: #185dff;
background: #3552ff;
color: #fff;
font-size: 22rpx;
font-weight: 600;
line-height: 1;
}
&__empty {
padding: 40rpx 0;
padding: 30rpx 0;
text-align: center;
color: #94a3b8;
font-size: 24rpx;
color: #9ca3af;
}
&__actions {
display: flex;
flex-direction: column;
gap: 18rpx;
}
:deep(.u-btn) {
flex: 1;
}
}
</style>