deploy: auto commit repo-root changes 2026-06-21 17:56:21
This commit is contained in:
@@ -428,10 +428,20 @@ class MatchController extends BaseApiController
|
|||||||
->order('id asc')
|
->order('id asc')
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
$liveMap = MatchLiveService::getLiveMapForApi(array_column($rows, 'id'));
|
||||||
|
|
||||||
$scheduleMap = [];
|
$scheduleMap = [];
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$roundName = $row['round_name'] ?: '未分轮次';
|
$roundName = $row['round_name'] ?: '未分轮次';
|
||||||
|
$liveList = $liveMap[(int) $row['id']] ?? [];
|
||||||
|
$defaultLiveUrl = '';
|
||||||
|
foreach ($liveList as $line) {
|
||||||
|
$candidate = trim((string) ($line['default_play_url'] ?? ''));
|
||||||
|
if ($candidate !== '') {
|
||||||
|
$defaultLiveUrl = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!isset($scheduleMap[$roundName])) {
|
if (!isset($scheduleMap[$roundName])) {
|
||||||
$scheduleMap[$roundName] = [
|
$scheduleMap[$roundName] = [
|
||||||
'round_name' => $roundName,
|
'round_name' => $roundName,
|
||||||
@@ -456,6 +466,8 @@ class MatchController extends BaseApiController
|
|||||||
'match_time' => (int) $row['match_time'],
|
'match_time' => (int) $row['match_time'],
|
||||||
'current_minute' => $row['current_minute'],
|
'current_minute' => $row['current_minute'],
|
||||||
'half_score' => $row['half_score'],
|
'half_score' => $row['half_score'],
|
||||||
|
'live_list' => $liveList,
|
||||||
|
'default_live_url' => $defaultLiveUrl,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,20 +23,38 @@ class MatchLiveService
|
|||||||
|
|
||||||
public static function getLiveListForApi(int $matchId): array
|
public static function getLiveListForApi(int $matchId): array
|
||||||
{
|
{
|
||||||
$rows = MatchLive::where('match_id', $matchId)
|
$liveMap = self::getLiveMapForApi([$matchId]);
|
||||||
|
|
||||||
|
return $liveMap[$matchId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getLiveMapForApi(array $matchIds): array
|
||||||
|
{
|
||||||
|
$matchIds = array_values(array_unique(array_filter(array_map('intval', $matchIds))));
|
||||||
|
if (empty($matchIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = MatchLive::whereIn('match_id', $matchIds)
|
||||||
->whereNull('delete_time')
|
->whereNull('delete_time')
|
||||||
->order('create_time', 'asc')
|
->order('create_time', 'asc')
|
||||||
->order('id', 'asc')
|
->order('id', 'asc')
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
|
$result = [];
|
||||||
foreach ($rows as &$row) {
|
foreach ($rows as &$row) {
|
||||||
$row['stream_options'] = self::buildStreamOptions($row);
|
$row['stream_options'] = self::buildStreamOptions($row);
|
||||||
$row['default_play_url'] = $row['stream_options'][0]['url'] ?? '';
|
$row['default_play_url'] = self::resolveDefaultPlayUrl($row);
|
||||||
|
$matchId = (int) ($row['match_id'] ?? 0);
|
||||||
|
if ($matchId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result[$matchId][] = $row;
|
||||||
}
|
}
|
||||||
unset($row);
|
unset($row);
|
||||||
|
|
||||||
return $rows;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function buildStreamOptions(array $row): array
|
public static function buildStreamOptions(array $row): array
|
||||||
@@ -62,4 +80,20 @@ class MatchLiveService
|
|||||||
|
|
||||||
return $options;
|
return $options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function resolveDefaultPlayUrl(array $row): string
|
||||||
|
{
|
||||||
|
if (!empty($row['stream_options'][0]['url'])) {
|
||||||
|
return trim((string) $row['stream_options'][0]['url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['play_url_m3u8', 'play_url_hd_m3u8', 'play_url_flv', 'play_url_hd_flv'] as $field) {
|
||||||
|
$url = trim((string) ($row[$field] ?? ''));
|
||||||
|
if ($url !== '') {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,26 @@
|
|||||||
<u-popup v-model="showPopup" mode="bottom" safe-area-inset-bottom border-radius="24" closeable @close="handleClose">
|
<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">
|
||||||
<view class="match-live-popup__header">
|
<view class="match-live-popup__header">
|
||||||
<text class="match-live-popup__title">{{ lineTitle }}</text>
|
<text class="match-live-popup__title">{{ popupTitle }}</text>
|
||||||
<text class="match-live-popup__desc">{{ lineMetaText }}</text>
|
<text class="match-live-popup__desc">{{ popupDesc }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="match-live-popup__section">
|
<view class="match-live-popup__section">
|
||||||
<text class="match-live-popup__section-title">直播线路</text>
|
<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
|
<view
|
||||||
v-for="item in streamOptions"
|
v-for="item in lineOptions"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
class="match-live-popup__option"
|
class="match-live-popup__option"
|
||||||
:class="{ 'match-live-popup__option--active': item.key === selectedStreamKey }"
|
:class="{ 'match-live-popup__option--active': item.key === selectedLineKey }"
|
||||||
@tap="selectOption(item)"
|
@tap="selectLine(item)"
|
||||||
>
|
>
|
||||||
<view class="match-live-popup__option-main">
|
<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>
|
<text class="match-live-popup__option-url">{{ item.url }}</text>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
<view v-else class="match-live-popup__empty">
|
<view v-else class="match-live-popup__empty">
|
||||||
@@ -38,17 +39,21 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
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
|
key: string
|
||||||
label: string
|
title: string
|
||||||
|
meta: string
|
||||||
url: string
|
url: string
|
||||||
|
directUrl: string
|
||||||
|
sourceUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
line: MatchLiveLineItem | null
|
matchTitle?: string
|
||||||
|
lines: MatchLiveLineItem[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits(['update:show', 'close'])
|
const emit = defineEmits(['update:show', 'close'])
|
||||||
@@ -58,15 +63,10 @@ const showPopup = computed({
|
|||||||
set: (value: boolean) => emit('update:show', value),
|
set: (value: boolean) => emit('update:show', value),
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedStreamKey = ref('')
|
const selectedLineKey = ref('')
|
||||||
|
|
||||||
const normalizeText = (value: unknown) => String(value || '').trim()
|
const normalizeText = (value: unknown) => String(value || '').trim()
|
||||||
|
|
||||||
const normalizeUrl = (value: unknown) => {
|
|
||||||
const text = normalizeText(value)
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFetchStatusText = (value: unknown) => {
|
const getFetchStatusText = (value: unknown) => {
|
||||||
const text = normalizeText(value)
|
const text = normalizeText(value)
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
@@ -96,110 +96,91 @@ const formatMetaTime = (value: unknown) => {
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineTitle = computed(() => {
|
const collectLineUrls = (line: MatchLiveLineItem) => {
|
||||||
const line = props.line || {}
|
const candidates: string[] = []
|
||||||
return (
|
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.title) ||
|
||||||
normalizeText(line.name) ||
|
normalizeText(line.name) ||
|
||||||
normalizeText(line.label) ||
|
normalizeText(line.label) ||
|
||||||
normalizeText(line.live_tag) ||
|
normalizeText(line.live_tag) ||
|
||||||
normalizeText(line.source_name) ||
|
normalizeText(line.source_name) ||
|
||||||
'直播线路'
|
`线路${index + 1}`
|
||||||
)
|
)
|
||||||
})
|
const metaParts = [
|
||||||
|
|
||||||
const lineMetaText = computed(() => {
|
|
||||||
const line = props.line || {}
|
|
||||||
const parts = [
|
|
||||||
getFetchStatusText(line.fetch_status),
|
getFetchStatusText(line.fetch_status),
|
||||||
formatMetaTime(line.last_fetch_at || line.update_time),
|
formatMetaTime(line.last_fetch_at || line.update_time),
|
||||||
].filter(Boolean)
|
].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 {
|
return {
|
||||||
...option,
|
...line,
|
||||||
key: `${index}-${url}`,
|
key: normalizeText(line.id) || `${title}-${index}`,
|
||||||
label:
|
title,
|
||||||
normalizeText(option.title) ||
|
meta: metaParts.length ? metaParts.join(' · ') : (directUrl ? '可直接播放' : '源站兜底'),
|
||||||
normalizeText(option.name) ||
|
|
||||||
normalizeText(option.label) ||
|
|
||||||
normalizeText(option.source_name) ||
|
|
||||||
normalizeText(option.quality) ||
|
|
||||||
`线路${index + 1}`,
|
|
||||||
url,
|
url,
|
||||||
} satisfies PopupStreamOption
|
directUrl,
|
||||||
|
sourceUrl,
|
||||||
|
} satisfies PopupLiveLineOption
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamOptions = computed(() => {
|
const lineOptions = computed(() => {
|
||||||
const line = props.line || {}
|
return (props.lines || [])
|
||||||
const options: PopupStreamOption[] = []
|
.map((line, index) => buildLineOption(line, index))
|
||||||
const seen = new Set<string>()
|
.filter((item): item is PopupLiveLineOption => !!item)
|
||||||
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 selectedLine = computed(() => {
|
||||||
|
return lineOptions.value.find((item) => item.key === selectedLineKey.value) || lineOptions.value[0] || null
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedStream = computed(() => {
|
const popupTitle = computed(() => normalizeText(props.matchTitle) || '切换线路')
|
||||||
return streamOptions.value.find((item) => item.key === selectedStreamKey.value) || streamOptions.value[0] || null
|
|
||||||
|
const popupDesc = computed(() => {
|
||||||
|
if (!lineOptions.value.length) return '暂无可切换的直播线路'
|
||||||
|
return selectedLine.value?.meta || `共 ${lineOptions.value.length} 条直播线路`
|
||||||
})
|
})
|
||||||
|
|
||||||
const resetSelected = () => {
|
const resetSelected = () => {
|
||||||
const defaultPlayUrl = normalizeUrl(props.line?.default_play_url)
|
selectedLineKey.value = lineOptions.value[0]?.key || ''
|
||||||
if (defaultPlayUrl) {
|
|
||||||
const matched = streamOptions.value.find((item) => item.url === defaultPlayUrl)
|
|
||||||
if (matched) {
|
|
||||||
selectedStreamKey.value = matched.key
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectedStreamKey.value = streamOptions.value[0]?.key || ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.line, props.show],
|
() => [props.lines, props.show],
|
||||||
() => {
|
() => {
|
||||||
resetSelected()
|
resetSelected()
|
||||||
},
|
},
|
||||||
@@ -210,12 +191,12 @@ const navigateToWebview = (url: string) => {
|
|||||||
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` })
|
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectOption = (item: PopupStreamOption) => {
|
const selectLine = (item: PopupLiveLineOption) => {
|
||||||
selectedStreamKey.value = item.key
|
selectedLineKey.value = item.key
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSelected = () => {
|
const openSelected = () => {
|
||||||
const url = selectedStream.value?.url || ''
|
const url = selectedLine.value?.directUrl || selectedLine.value?.sourceUrl || ''
|
||||||
if (!url) {
|
if (!url) {
|
||||||
uni.showToast({ title: '暂无可用直播链接', icon: 'none' })
|
uni.showToast({ title: '暂无可用直播链接', icon: 'none' })
|
||||||
return
|
return
|
||||||
@@ -224,11 +205,12 @@ const openSelected = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openSource = () => {
|
const openSource = () => {
|
||||||
if (!sourceUrl.value) {
|
const url = selectedLine.value?.sourceUrl || selectedLine.value?.directUrl || ''
|
||||||
|
if (!url) {
|
||||||
uni.showToast({ title: '暂无源站链接', icon: 'none' })
|
uni.showToast({ title: '暂无源站链接', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
navigateToWebview(sourceUrl.value)
|
navigateToWebview(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -282,15 +264,15 @@ const handleClose = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16rpx;
|
gap: 18rpx;
|
||||||
padding: 22rpx 24rpx;
|
padding: 20rpx 22rpx;
|
||||||
border: 2rpx solid #e5e7eb;
|
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
background: #f9fafb;
|
background: #f8fafc;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
border-color: #185dff;
|
border-color: #3552ff;
|
||||||
background: rgba(24, 93, 255, 0.08);
|
background: rgba(53, 82, 255, 0.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,18 +280,26 @@ const handleClose = () => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8rpx;
|
gap: 6rpx;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__option-title {
|
&__option-title {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #111827;
|
color: #0f172a;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__option-meta {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__option-url {
|
&__option-url {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #6b7280;
|
color: #3552ff;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -318,23 +308,26 @@ const handleClose = () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 8rpx 16rpx;
|
padding: 8rpx 16rpx;
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
background: #185dff;
|
background: #3552ff;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
font-weight: 600;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
padding: 40rpx 0;
|
padding: 30rpx 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #9ca3af;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: 18rpx;
|
gap: 18rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.u-btn) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -31,7 +31,42 @@
|
|||||||
|
|
||||||
<scroll-view scroll-y scroll-with-animation class="worldcup-scroll"
|
<scroll-view scroll-y scroll-with-animation class="worldcup-scroll"
|
||||||
:scroll-into-view="activeTab === 'schedule' ? scheduleScrollIntoView : ''">
|
:scroll-into-view="activeTab === 'schedule' ? scheduleScrollIntoView : ''">
|
||||||
<block v-if="activeTab === 'schedule'">
|
<block v-if="activeTab === 'live'">
|
||||||
|
<view v-if="liveMatches.length" class="worldcup-live-grid">
|
||||||
|
<view
|
||||||
|
v-for="match in liveMatches"
|
||||||
|
:key="`live-${match.id || match.match_id}`"
|
||||||
|
class="worldcup-live-card"
|
||||||
|
@tap="openWorldCupLive(match)"
|
||||||
|
>
|
||||||
|
<view class="worldcup-live-card__top">
|
||||||
|
<text class="worldcup-live-card__badge">直播</text>
|
||||||
|
<text class="worldcup-live-card__count">{{ getMatchLiveLines(match).length }}条线路</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="worldcup-live-card__teams">
|
||||||
|
<text class="worldcup-live-card__team">{{ match.home_team }}</text>
|
||||||
|
<text class="worldcup-live-card__vs">VS</text>
|
||||||
|
<text class="worldcup-live-card__team">{{ match.away_team }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="worldcup-live-card__meta">
|
||||||
|
<text>{{ matchMetaText(match, 0) }}</text>
|
||||||
|
<text>{{ formatMatchTime(match.match_time) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="worldcup-live-card__footer">
|
||||||
|
<text class="worldcup-live-card__line">{{ getPrimaryLiveLineTitle(match) }}</text>
|
||||||
|
<text class="worldcup-live-card__switch" @tap.stop="openLiveLinePopup(match)">切换线路</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="worldcup-empty">
|
||||||
|
<u-empty text="暂无直播" mode="data" />
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block v-else-if="activeTab === 'schedule'">
|
||||||
<view class="worldcup-sortbar" @tap="toggleScheduleSort">
|
<view class="worldcup-sortbar" @tap="toggleScheduleSort">
|
||||||
<text class="worldcup-sortbar__label">{{ scheduleSortLabel }}</text>
|
<text class="worldcup-sortbar__label">{{ scheduleSortLabel }}</text>
|
||||||
<text class="worldcup-sortbar__icon">↕</text>
|
<text class="worldcup-sortbar__icon">↕</text>
|
||||||
@@ -193,6 +228,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
|
<MatchLivePopup
|
||||||
|
v-model:show="showLivePopup"
|
||||||
|
:match-title="livePopupMatchTitle"
|
||||||
|
:lines="livePopupLines"
|
||||||
|
@close="handleLivePopupClose"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -201,12 +243,18 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|||||||
import RankingNav from '../pages/worldcup/components/ranking/RankingNav.vue'
|
import RankingNav from '../pages/worldcup/components/ranking/RankingNav.vue'
|
||||||
import StandingsPanel from '../pages/worldcup/components/ranking/StandingsPanel.vue'
|
import StandingsPanel from '../pages/worldcup/components/ranking/StandingsPanel.vue'
|
||||||
import PlayerRankingPanel from '../pages/worldcup/components/ranking/PlayerRankingPanel.vue'
|
import PlayerRankingPanel from '../pages/worldcup/components/ranking/PlayerRankingPanel.vue'
|
||||||
|
import MatchLivePopup from '@/components/match-live-popup/match-live-popup.vue'
|
||||||
import TranslatedArticleCard from '@/components/translated-article-card/translated-article-card.vue'
|
import TranslatedArticleCard from '@/components/translated-article-card/translated-article-card.vue'
|
||||||
import { getArticleCate, getArticleList } from '@/api/news'
|
import { getArticleCate, getArticleList } from '@/api/news'
|
||||||
import { getWorldCupRankings, getWorldCupSchedule, getWorldCupStandings } from '@/api/match'
|
import {
|
||||||
|
getWorldCupRankings,
|
||||||
|
getWorldCupSchedule,
|
||||||
|
getWorldCupStandings,
|
||||||
|
type MatchLiveLineItem,
|
||||||
|
} from '@/api/match'
|
||||||
import { getLocalMatchDate } from '@/utils/match-time'
|
import { getLocalMatchDate } from '@/utils/match-time'
|
||||||
|
|
||||||
type MainTab = 'schedule' | 'rank' | 'bracket' | 'news'
|
type MainTab = 'live' | 'schedule' | 'rank' | 'bracket' | 'news'
|
||||||
type RankingMode = 'standings' | 'goals' | 'assists'
|
type RankingMode = 'standings' | 'goals' | 'assists'
|
||||||
type ScheduleSort = 'time' | 'group'
|
type ScheduleSort = 'time' | 'group'
|
||||||
type BracketTabKey = '1/16决赛' | '1/8决赛' | '1/4决赛' | '半决赛' | '决赛'
|
type BracketTabKey = '1/16决赛' | '1/8决赛' | '1/4决赛' | '半决赛' | '决赛'
|
||||||
@@ -224,6 +272,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const tabs: { key: MainTab; label: string }[] = [
|
const tabs: { key: MainTab; label: string }[] = [
|
||||||
|
{ key: 'live', label: '直播' },
|
||||||
{ key: 'schedule', label: '比分和赛程' },
|
{ key: 'schedule', label: '比分和赛程' },
|
||||||
{ key: 'rank', label: '排名' },
|
{ key: 'rank', label: '排名' },
|
||||||
{ key: 'bracket', label: '对阵图' },
|
{ key: 'bracket', label: '对阵图' },
|
||||||
@@ -240,11 +289,13 @@ const bracketTabs: { key: BracketTabKey; label: string }[] = [
|
|||||||
{ key: '决赛', label: '决赛' }
|
{ key: '决赛', label: '决赛' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeTab = ref<MainTab>(tabs[0].key)
|
const activeTab = ref<MainTab>('schedule')
|
||||||
const rankingMode = ref<RankingMode>('standings')
|
const rankingMode = ref<RankingMode>('standings')
|
||||||
const scheduleSort = ref<ScheduleSort>('time')
|
const scheduleSort = ref<ScheduleSort>('time')
|
||||||
const activeBracketTab = ref<BracketTabKey>(bracketTabs[0].key)
|
const activeBracketTab = ref<BracketTabKey>(bracketTabs[0].key)
|
||||||
const scheduleScrollIntoView = ref('')
|
const scheduleScrollIntoView = ref('')
|
||||||
|
const showLivePopup = ref(false)
|
||||||
|
const livePopupMatch = ref<any | null>(null)
|
||||||
const statusBarHeight = ref(0)
|
const statusBarHeight = ref(0)
|
||||||
|
|
||||||
const scheduleData = ref<any>({
|
const scheduleData = ref<any>({
|
||||||
@@ -382,6 +433,66 @@ const resolveBracketTeamIcon = (rawValue: any, fallback = '') => {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeLiveText = (value: unknown) => String(value || '').trim()
|
||||||
|
|
||||||
|
const getMatchLiveLines = (match: any): MatchLiveLineItem[] => {
|
||||||
|
return Array.isArray(match?.live_list) ? match.live_list : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectLineUrls = (line: 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(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 getLineDirectUrl = (line: MatchLiveLineItem) => collectLineUrls(line)[0] || ''
|
||||||
|
|
||||||
|
const getLineSourceUrl = (line: MatchLiveLineItem) => normalizeLiveText(line.source_url || line.live_url)
|
||||||
|
|
||||||
|
const getPrimaryLiveLine = (match: any) => {
|
||||||
|
return getMatchLiveLines(match).find((line) => getLineDirectUrl(line) || getLineSourceUrl(line)) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPrimaryLiveLineTitle = (match: any) => {
|
||||||
|
const line = getPrimaryLiveLine(match)
|
||||||
|
if (!line) return '点击进入直播'
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizeLiveText(line.title) ||
|
||||||
|
normalizeLiveText(line.name) ||
|
||||||
|
normalizeLiveText(line.label) ||
|
||||||
|
normalizeLiveText(line.live_tag) ||
|
||||||
|
normalizeLiveText(line.source_name) ||
|
||||||
|
'点击进入直播'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const flattenScheduleMatches = computed(() => {
|
const flattenScheduleMatches = computed(() => {
|
||||||
const matches: any[] = []
|
const matches: any[] = []
|
||||||
; (scheduleData.value.schedule_rounds || []).forEach((round: any) => {
|
; (scheduleData.value.schedule_rounds || []).forEach((round: any) => {
|
||||||
@@ -396,6 +507,13 @@ const flattenScheduleMatches = computed(() => {
|
|||||||
return matches.sort((a, b) => (a.match_time || 0) - (b.match_time || 0))
|
return matches.sort((a, b) => (a.match_time || 0) - (b.match_time || 0))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const liveMatches = computed(() => {
|
||||||
|
return flattenScheduleMatches.value.filter((match) => {
|
||||||
|
if ((match?.status ?? 0) === 2) return false
|
||||||
|
return !!getPrimaryLiveLine(match)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const scheduleGroups = computed(() => {
|
const scheduleGroups = computed(() => {
|
||||||
const groups: Array<{ key: string; label: string; items: any[] }> = []
|
const groups: Array<{ key: string; label: string; items: any[] }> = []
|
||||||
|
|
||||||
@@ -459,6 +577,14 @@ const initLayout = () => {
|
|||||||
|
|
||||||
const scheduleGroupAnchorId = (key: string) => `worldcup-schedule-group-${key}`
|
const scheduleGroupAnchorId = (key: string) => `worldcup-schedule-group-${key}`
|
||||||
|
|
||||||
|
const livePopupLines = computed(() => getMatchLiveLines(livePopupMatch.value))
|
||||||
|
|
||||||
|
const livePopupMatchTitle = computed(() => {
|
||||||
|
const match = livePopupMatch.value
|
||||||
|
if (!match) return '切换线路'
|
||||||
|
return `${match.home_team || ''} VS ${match.away_team || ''}`.trim() || '切换线路'
|
||||||
|
})
|
||||||
|
|
||||||
const fetchSchedule = async () => {
|
const fetchSchedule = async () => {
|
||||||
scheduleData.value = await getWorldCupSchedule() || { current_round_name: '', bracket_rounds: [], schedule_rounds: [] }
|
scheduleData.value = await getWorldCupSchedule() || { current_round_name: '', bracket_rounds: [], schedule_rounds: [] }
|
||||||
if (activeTab.value === 'schedule') {
|
if (activeTab.value === 'schedule') {
|
||||||
@@ -505,7 +631,7 @@ const fetchNews = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ensureTabData = async (tab: MainTab) => {
|
const ensureTabData = async (tab: MainTab) => {
|
||||||
if (tab === 'schedule') {
|
if (tab === 'live' || tab === 'schedule') {
|
||||||
await fetchSchedule()
|
await fetchSchedule()
|
||||||
} else if (tab === 'rank') {
|
} else if (tab === 'rank') {
|
||||||
if (rankingMode.value === 'standings') {
|
if (rankingMode.value === 'standings') {
|
||||||
@@ -532,6 +658,33 @@ const toggleScheduleSort = () => {
|
|||||||
scheduleSort.value = scheduleSort.value === 'time' ? 'group' : 'time'
|
scheduleSort.value = scheduleSort.value === 'time' ? 'group' : 'time'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navigateToWebview = (url: string) => {
|
||||||
|
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const openWorldCupLive = (match: any) => {
|
||||||
|
const primaryLine = getPrimaryLiveLine(match)
|
||||||
|
const url = primaryLine ? (getLineDirectUrl(primaryLine) || getLineSourceUrl(primaryLine)) : ''
|
||||||
|
if (!url) {
|
||||||
|
uni.showToast({ title: '暂无可用直播线路', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigateToWebview(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLiveLinePopup = (match: any) => {
|
||||||
|
if (!getMatchLiveLines(match).length) {
|
||||||
|
uni.showToast({ title: '暂无直播线路', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
livePopupMatch.value = match
|
||||||
|
showLivePopup.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLivePopupClose = () => {
|
||||||
|
livePopupMatch.value = null
|
||||||
|
}
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
emit('back')
|
emit('back')
|
||||||
}
|
}
|
||||||
@@ -636,7 +789,9 @@ const initViewState = async () => {
|
|||||||
|
|
||||||
const legacyTab = String(props.initialTab || 'schedule')
|
const legacyTab = String(props.initialTab || 'schedule')
|
||||||
await resolveNewsCategoryId(props.initialCid)
|
await resolveNewsCategoryId(props.initialCid)
|
||||||
if (legacyTab === 'schedule') {
|
if (legacyTab === 'live') {
|
||||||
|
activeTab.value = 'live'
|
||||||
|
} else if (legacyTab === 'schedule') {
|
||||||
activeTab.value = 'schedule'
|
activeTab.value = 'schedule'
|
||||||
} else if (legacyTab === 'rank' || legacyTab === 'standings') {
|
} else if (legacyTab === 'rank' || legacyTab === 'standings') {
|
||||||
activeTab.value = 'rank'
|
activeTab.value = 'rank'
|
||||||
@@ -876,6 +1031,92 @@ onMounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.worldcup-live-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12rpx;
|
||||||
|
padding-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldcup-live-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20rpx 18rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
|
box-shadow: 0 10rpx 28rpx rgba(37, 99, 235, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldcup-live-card__top,
|
||||||
|
.worldcup-live-card__meta,
|
||||||
|
.worldcup-live-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldcup-live-card__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 68rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(53, 82, 255, 0.12);
|
||||||
|
color: #3552ff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldcup-live-card__count,
|
||||||
|
.worldcup-live-card__meta {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldcup-live-card__teams {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldcup-live-card__team {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldcup-live-card__vs {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldcup-live-card__line {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #1e293b;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldcup-live-card__switch {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #3552ff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.wc-match-card {
|
.wc-match-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="match-detail">
|
<view class="match-detail">
|
||||||
<match-detail-header
|
<match-detail-header :status-bar-height="statusBarHeight" title="赛事详情"
|
||||||
:status-bar-height="statusBarHeight"
|
:subtitle="match.league_name || '赛事信息加载中'" @back="goBack" @share="showSharePopup = true" />
|
||||||
title="赛事详情"
|
|
||||||
:subtitle="match.league_name || '赛事信息加载中'"
|
|
||||||
@back="goBack"
|
|
||||||
@share="showSharePopup = true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 加载中 -->
|
<!-- 加载中 -->
|
||||||
<view class="detail-loading" v-if="loading">
|
<view class="detail-loading" v-if="loading">
|
||||||
@@ -63,31 +58,66 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="liveList.length" class="live-entrance">
|
<view v-if="liveList.length" class="live-entrance">
|
||||||
<view class="live-entrance__header">
|
<view class="live-entrance__player-card">
|
||||||
<text class="live-entrance__heading">直播线路</text>
|
<view class="live-entrance__player-header">
|
||||||
<text class="live-entrance__subheading">选择线路后通过内置 WebView 打开</text>
|
<view class="live-entrance__player-title-wrap">
|
||||||
|
<text class="live-entrance__player-title">{{ activeLiveLineTitle }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-for="(item, index) in liveList" :key="item.id || item.title || item.name || index"
|
<view class="live-entrance__player-actions">
|
||||||
class="live-entrance__card" @tap="openLiveLine(item)">
|
<u-button v-if="activeLivePlayUrl" size="mini" shape="circle" plain
|
||||||
<view class="live-entrance__left">
|
@click.stop="openLiveFullscreen">
|
||||||
<view class="live-entrance__badge">
|
全屏播放
|
||||||
|
</u-button>
|
||||||
|
<u-button size="mini" shape="circle" plain @click.stop="toggleLiveLineSelector">
|
||||||
|
{{ showLiveLineSelector ? '收起线路' : '切换线路' }}
|
||||||
|
</u-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<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>
|
<text>{{ index + 1 }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="live-entrance__content">
|
<view class="live-entrance__selector-content">
|
||||||
<text class="live-entrance__title">{{ getLiveLineTitle(item) }}</text>
|
<text class="live-entrance__selector-title">{{ item.displayTitle }}</text>
|
||||||
<text class="live-entrance__desc">{{ getLiveLineMeta(item) }}</text>
|
<text class="live-entrance__selector-desc">{{ item.metaText }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="live-entrance__action">
|
<text class="live-entrance__selector-tag">
|
||||||
<text>切换线路</text>
|
{{ item.lineKey === activeLiveLineKey ? '当前播放' : item.hasDirectPlay ? '点击切换' : '源站兜底' }}
|
||||||
<u-icon name="arrow-right" size="26" color="#c7d2fe"></u-icon>
|
</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Tab 导航栏 -->
|
<!-- Tab 导航栏 -->
|
||||||
<view v-else-if="thirdPartyLiveUrl" class="live-entrance">
|
<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__left">
|
||||||
<view class="live-entrance__badge">
|
<view class="live-entrance__badge">
|
||||||
<text>直播</text>
|
<text>直播</text>
|
||||||
@@ -435,8 +465,6 @@
|
|||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<MatchLivePopup v-model:show="showLivePopup" :line="selectedLiveLine" @close="handleLivePopupClose" />
|
|
||||||
|
|
||||||
<!-- 分享弹窗 -->
|
<!-- 分享弹窗 -->
|
||||||
<SharePopup v-if="showSharePopup" v-model:show="showSharePopup" page-type="match"
|
<SharePopup v-if="showSharePopup" v-model:show="showSharePopup" page-type="match"
|
||||||
:path="`/pages/match_detail/match_detail?id=${match.id || ''}`"
|
:path="`/pages/match_detail/match_detail?id=${match.id || ''}`"
|
||||||
@@ -446,9 +474,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 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 SharePopup from '@/components/share-popup/share-popup.vue'
|
||||||
import GlobalPopup from '@/components/global-popup/global-popup.vue'
|
import GlobalPopup from '@/components/global-popup/global-popup.vue'
|
||||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||||
@@ -458,6 +485,15 @@ import { checkAiUnlock, getMatchPredictSection } from '@/api/ai'
|
|||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { getLocalMatchDate } from '@/utils/match-time'
|
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 userStore = useUserStore()
|
||||||
const vipInfo = computed(() => userStore.userInfo?.vip_info || {})
|
const vipInfo = computed(() => userStore.userInfo?.vip_info || {})
|
||||||
const isVipUser = computed(() => !!vipInfo.value.is_vip)
|
const isVipUser = computed(() => !!vipInfo.value.is_vip)
|
||||||
@@ -474,8 +510,10 @@ const aiInlineLoading = ref(false)
|
|||||||
const aiInline = ref<any>({})
|
const aiInline = ref<any>({})
|
||||||
const showSharePopup = ref(false)
|
const showSharePopup = ref(false)
|
||||||
const liveTextList = ref<any[]>([])
|
const liveTextList = ref<any[]>([])
|
||||||
const showLivePopup = ref(false)
|
const activeLiveLineKey = ref('')
|
||||||
const selectedLiveLine = ref<MatchLiveLineItem | null>(null)
|
const activeLiveError = ref('')
|
||||||
|
const livePlayerLoading = ref(false)
|
||||||
|
const showLiveLineSelector = ref(false)
|
||||||
let liveTextTimer: ReturnType<typeof setInterval> | null = null
|
let liveTextTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const thirdPartyLiveUrl = computed(() => String(match.value?.live_url || '').trim())
|
const thirdPartyLiveUrl = computed(() => String(match.value?.live_url || '').trim())
|
||||||
@@ -557,6 +595,131 @@ const getLiveLineMeta = (item: MatchLiveLineItem) => {
|
|||||||
return parts.length ? parts.join(' · ') : '点击选择直播线路'
|
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 techStatsList = computed(() => {
|
||||||
const m = match.value as any
|
const m = match.value as any
|
||||||
// 优先使用 tech_stats JSON(兼容所有运动类型)
|
// 优先使用 tech_stats JSON(兼容所有运动类型)
|
||||||
@@ -617,8 +780,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
const fetchDetail = async (id: number) => {
|
const fetchDetail = async (id: number) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
showLivePopup.value = false
|
activeLiveError.value = ''
|
||||||
selectedLiveLine.value = null
|
|
||||||
try {
|
try {
|
||||||
const data = await getMatchDetail({ id })
|
const data = await getMatchDetail({ id })
|
||||||
match.value = data || {}
|
match.value = data || {}
|
||||||
@@ -674,8 +836,7 @@ const fetchLineup = async (matchId: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openThirdPartyLive = () => {
|
const navigateToLiveWebview = (url: string) => {
|
||||||
const url = thirdPartyLiveUrl.value
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
uni.showToast({ title: '暂无直播链接', icon: 'none' })
|
uni.showToast({ title: '暂无直播链接', icon: 'none' })
|
||||||
return
|
return
|
||||||
@@ -683,13 +844,67 @@ const openThirdPartyLive = () => {
|
|||||||
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` })
|
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
const openLiveLine = (item: MatchLiveLineItem) => {
|
const openThirdPartyLive = () => {
|
||||||
selectedLiveLine.value = item
|
navigateToLiveWebview(thirdPartyLiveUrl.value)
|
||||||
showLivePopup.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLivePopupClose = () => {
|
const selectLiveLine = (item: MatchDetailLiveLine) => {
|
||||||
showLivePopup.value = false
|
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) => {
|
const formatLiveTime = (ts: any) => {
|
||||||
@@ -1652,27 +1867,193 @@ onUnload(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16rpx;
|
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;
|
display: flex;
|
||||||
align-items: flex-end;
|
flex-direction: column;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__player-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16rpx;
|
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-size: 30rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #111827;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__subheading {
|
&__player-meta {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #6b7280;
|
color: #64748b;
|
||||||
text-align: right;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
Reference in New Issue
Block a user