Files
thebet365/apps/player/src/views/desktop/DesktopHomeView.vue

1630 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import DesktopBanner3DCarousel from '../../components/desktop/DesktopBanner3DCarousel.vue';
import HomeAnnouncementCard from '../../components/HomeAnnouncementCard.vue';
import AnnouncementMarquee from '../../components/AnnouncementMarquee.vue';
import { usePlayerHome } from '../../composables/usePlayerHome';
import TeamEmblem from '../../components/TeamEmblem.vue';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import type { PlayerHomeMatch } from '../../composables/usePlayerHome';
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
import { resolveSelectionLabel } from '../../utils/selectionLabel';
import MarketSelectionsPanel from '../../components/match-detail/MarketSelectionsPanel.vue';
const { t, locale } = useI18n();
const router = useRouter();
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
const { openAt, visible: popoverVisible, pendingItem, cancelClose, scheduleClose, isActiveForCard } = useDesktopBetPopover();
const activeMarketTypes = ref<Record<string, string>>({});
const bannerFallbackTo = computed(() => {
const id = announcementItems.value[0]?.id;
return id ? `/announcements/${id}` : '/announcements';
});
const featuredMatch = computed<PlayerHomeMatch | null>(() => hotMatches.value[0] ?? null);
const restHotMatches = computed<PlayerHomeMatch[]>(() => hotMatches.value.slice(1));
const upcomingList = computed<PlayerHomeMatch[]>(() => upcomingMatches.value);
const sideRecommendMatches = computed<PlayerHomeMatch[]>(() => hotMatches.value.slice(1, 5));
const marqueeItems = computed<string[]>(() =>
announcementItems.value
.map((it) => it.translation?.title?.trim() || '')
.filter((s): s is string => Boolean(s)),
);
onMounted(() => {
void load(true);
});
function goMatch(id: string) {
router.push(`/match/${id}`);
}
function goPath(path: string) {
router.push(path);
}
function formatKickoff(startTime: string) {
return formatLocalMatchDateTime(startTime, locale.value, {
variant: 'compact',
includeTimeZone: false,
});
}
function getActiveMarketType(match: PlayerHomeMatch) {
const matchId = match.id;
if (activeMarketTypes.value[matchId]) {
return activeMarketTypes.value[matchId];
}
if (match.markets?.some(m => m.marketType === 'FT_1X2')) {
return 'FT_1X2';
}
return match.markets?.[0]?.marketType || 'FT_1X2';
}
function setActiveMarketType(matchId: string, marketType: string) {
activeMarketTypes.value[matchId] = marketType;
}
function getAvailableMarketsForMatch(match: PlayerHomeMatch) {
const supported = ['FT_1X2', 'FT_HANDICAP', 'FT_OVER_UNDER'];
return (match.markets ?? []).filter(m => supported.includes(m.marketType));
}
function getActiveMarket(match: PlayerHomeMatch) {
const activeType = getActiveMarketType(match);
return match.markets?.find(m => m.marketType === activeType);
}
function getMarketTabLabel(type: string) {
const raw = (() => {
if (type === 'FT_1X2') return t('bet.market_ft_1x2');
if (type === 'FT_HANDICAP') return t('bet.market_ft_handicap');
if (type === 'FT_OVER_UNDER') return t('bet.market_ft_ou');
return type;
})();
return raw.replace(/全场\s*|FT\s*|Penuh\s*/ig, '').trim();
}
function isMarketLocked(match: PlayerHomeMatch, market?: { status: string; allowSingle?: boolean; allowParlay?: boolean }) {
if (!market) return true;
if (match.bettingOpen === false) return true;
const status = (market.status ?? 'OPEN').toUpperCase();
if (status !== 'OPEN') return true;
return market.allowSingle !== true && market.allowParlay !== true;
}
function isSelected(id: string) {
return false;
}
function handleOddsClick(match: PlayerHomeMatch, market: any, selId: string, event?: MouseEvent) {
if (isMarketLocked(match, market)) return;
const sel = market.selections?.find((s: any) => s.id === selId);
if (!sel || !event) return;
const item = {
selectionId: sel.id,
oddsVersion: String(sel.oddsVersion),
matchId: match.id,
matchName: `${match.homeTeamName} vs ${match.awayTeamName}`,
marketId: market.id,
marketName: market.marketDisplayName?.trim() || market.marketType || '',
selectionName: resolveSelectionLabel(t, sel.selectionCode || '', sel.selectionName, {
lineValue: market.lineValue,
}),
odds: parseFloat(sel.odds),
marketType: market.marketType,
lineValue: market.lineValue != null && market.lineValue !== '' ? parseFloat(String(market.lineValue)) : null,
allowSingle: market.allowSingle,
allowParlay: market.allowParlay,
};
openAt(event, item);
}
function getSelLabel(sel: any, marketType: string, lineValue?: string | number | null) {
return resolveSelectionLabel(t, sel.selectionCode || '', sel.selectionName || '', { lineValue });
}
function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: string) {
if (!match || !popoverVisible.value || !pendingItem.value) return '';
if (!isActiveForCard(cardRootId)) return '';
if (String(pendingItem.value.matchId) !== String(match.id)) return '';
const selId = pendingItem.value.selectionId;
// Scan all markets across all tabs to find the selection
for (const market of match.markets ?? []) {
const sel = market.selections?.find((s: any) => s.id === selId);
if (!sel) continue;
const code = (sel.selectionCode || '').toUpperCase();
if (code === 'HOME') return 'home';
if (code === 'AWAY') return 'away';
if (code === 'DRAW') return 'draw';
if (code === 'OVER') return 'home';
if (code === 'UNDER') return 'away';
if (code === 'ODD' || code === 'EVEN') return 'draw';
// Fallback: use selection index
const selIndex = market.selections?.findIndex((s: any) => s.id === selId) ?? -1;
if (selIndex === -1) continue;
if (market.marketType === 'FT_1X2') {
if (selIndex === 0) return 'home';
if (selIndex === 1) return 'draw';
if (selIndex === 2) return 'away';
} else {
if (selIndex === 0) return 'home';
if (selIndex === 1) return 'away';
}
}
return '';
}
</script>
<template>
<div class="desktop-home-view">
<!-- [B] Hero Row: 3D Banner -->
<section class="hero-row anim-fade-up" style="--anim-delay:0ms">
<div class="hero-banner-full">
<DesktopBanner3DCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
</div>
</section>
<!-- [C] 跑马灯金色条仅在有数据时展示 -->
<div v-if="marqueeItems.length" class="home-marquee-strip anim-fade-up" style="--anim-delay:80ms">
<AnnouncementMarquee :items="marqueeItems" embedded />
</div>
<!-- [D] 主区: 左主内容 + 右侧栏 -->
<div class="main-row">
<div class="main-col">
<!-- [D1] 焦点赛事 -->
<section class="content-section anim-fade-up" style="--anim-delay:160ms">
<div class="section-title">
<span class="st-line st-line-animated" aria-hidden="true"></span>
<span class="st-text">{{ t('home.section_featured') }}</span>
</div>
<div v-if="loading && !hotMatches.length" class="loading-state">
<GoldSpinner :active="true" />
</div>
<template v-else-if="featuredMatch">
<!-- 焦点大卡 -->
<div
class="featured-match anim-fade-up"
:class="{ 'featured-match--engaged': isActiveForCard(`featured-${featuredMatch.id}`) }"
:data-bet-card-root="`featured-${featuredMatch.id}`"
style="--anim-delay:240ms"
@click.self="goMatch(featuredMatch.id)"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="featured-top" @click="goMatch(featuredMatch.id)">
<div class="featured-meta">
<span class="league-tag lg">{{ featuredMatch.leagueName }}</span>
<span
v-if="(featuredMatch as any).status === 'LIVE'"
class="live-indicator lg live-pulse"
>LIVE</span>
</div>
<span class="featured-time">{{ formatKickoff(featuredMatch.startTime) }}</span>
</div>
<div class="featured-teams" :class="getHoveredSideForMatch(featuredMatch, `featured-${featuredMatch.id}`)" @click="goMatch(featuredMatch.id)">
<div class="featured-team">
<TeamEmblem
size="lg"
:team-code="featuredMatch.homeTeamCode"
:team-name="featuredMatch.homeTeamName"
:logo-url="featuredMatch.homeTeamLogoUrl"
/>
<span class="featured-team-name">{{ featuredMatch.homeTeamName }}</span>
</div>
<div class="featured-vs vs-heartbeat">VS</div>
<div class="featured-team">
<TeamEmblem
size="lg"
:team-code="featuredMatch.awayTeamCode"
:team-name="featuredMatch.awayTeamName"
:logo-url="featuredMatch.awayTeamLogoUrl"
/>
<span class="featured-team-name">{{ featuredMatch.awayTeamName }}</span>
</div>
</div>
<div class="featured-markets">
<div
v-for="m in getAvailableMarketsForMatch(featuredMatch)"
:key="m.marketType"
class="featured-market-block"
>
<div class="featured-market-label">{{ getMarketTabLabel(m.marketType) }}</div>
<MarketSelectionsPanel
desktop-card
compact
:locked="isMarketLocked(featuredMatch, m)"
:line-value="m.lineValue"
:selections="m.selections || []"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(featuredMatch!, m, selId, ev)"
/>
</div>
<div v-if="!getAvailableMarketsForMatch(featuredMatch).length" class="no-market-odds">
{{ t('bet.no_market_odds') || '暂无盘口赔率' }}
</div>
</div>
</div>
<!-- 其余热门 2 -->
<div v-if="restHotMatches.length" class="matches-grid">
<div
v-for="(match, idx) in restHotMatches"
:key="match.id"
class="match-card anim-fade-up"
:class="{ 'match-card--engaged': isActiveForCard(`hot-${match.id}`) }"
:data-bet-card-root="`hot-${match.id}`"
:style="{ '--anim-delay': (320 + idx * 60) + 'ms' }"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="match-info-side" @click="goMatch(match.id)">
<!-- Default info layout (visible at rest) -->
<div class="default-info-layout">
<div class="card-top">
<span class="league-tag">{{ match.leagueName }}</span>
<div class="top-meta">
<span class="live-indicator live-pulse" v-if="(match as any).status === 'LIVE'">LIVE</span>
<span class="match-time">{{ formatKickoff(match.startTime) }}</span>
</div>
</div>
<div class="teams-versus-compact">
<div class="team-row">
<TeamEmblem
size="sm"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="team-name">{{ match.homeTeamName }}</span>
</div>
<div class="vs-text">VS</div>
<div class="team-row">
<TeamEmblem
size="sm"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="team-name">{{ match.awayTeamName }}</span>
</div>
</div>
</div>
<!-- Hover versus layout (visible on hover) -->
<div class="hover-versus-layout" :class="getHoveredSideForMatch(match, `hot-${match.id}`)">
<div class="hover-team home-slide">
<TeamEmblem
size="md"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="hover-team-name">{{ match.homeTeamName }}</span>
</div>
<div class="hover-vs-glow">VS</div>
<div class="hover-team away-slide">
<TeamEmblem
size="md"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="hover-team-name">{{ match.awayTeamName }}</span>
</div>
</div>
</div>
<div class="match-market-side">
<div class="market-tabs">
<button
v-for="m in getAvailableMarketsForMatch(match)"
:key="m.marketType"
type="button"
class="market-tab-btn"
:class="{ active: getActiveMarketType(match) === m.marketType }"
@click="setActiveMarketType(match.id, m.marketType)"
>
{{ getMarketTabLabel(m.marketType) }}
</button>
</div>
<div class="market-odds-area">
<template v-if="getActiveMarket(match)">
<MarketSelectionsPanel
desktop-card
compact
:locked="isMarketLocked(match, getActiveMarket(match))"
:line-value="getActiveMarket(match)?.lineValue"
:selections="getActiveMarket(match)?.selections || []"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(match, getActiveMarket(match)!, selId, ev)"
/>
</template>
<div v-else class="no-market-odds">
{{ t('bet.no_market_odds') || '暂无盘口赔率' }}
</div>
</div>
</div>
</div>
</div>
</template>
<div v-else class="empty-state">
<p>{{ t('home.no_matches') }}</p>
</div>
</section>
<!-- [D2] 即将开赛 -->
<section class="content-section anim-fade-up" style="--anim-delay:200ms">
<div class="section-title">
<span class="st-line" aria-hidden="true"></span>
<span class="st-text">{{ t('home.section_upcoming') }}</span>
</div>
<div v-if="upcomingList.length" class="matches-grid">
<div
v-for="(match, idx) in upcomingList"
:key="match.id"
class="match-card anim-fade-up"
:class="{ 'match-card--engaged': isActiveForCard(`upcoming-${match.id}`) }"
:data-bet-card-root="`upcoming-${match.id}`"
:style="{ '--anim-delay': (260 + idx * 50) + 'ms' }"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="match-info-side" @click="goMatch(match.id)">
<!-- Default info layout (visible at rest) -->
<div class="default-info-layout">
<div class="card-top">
<span class="league-tag">{{ match.leagueName }}</span>
<div class="top-meta">
<span class="match-time">{{ formatKickoff(match.startTime) }}</span>
</div>
</div>
<div class="teams-versus-compact">
<div class="team-row">
<TeamEmblem
size="sm"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="team-name">{{ match.homeTeamName }}</span>
</div>
<div class="vs-text">VS</div>
<div class="team-row">
<TeamEmblem
size="sm"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="team-name">{{ match.awayTeamName }}</span>
</div>
</div>
</div>
<!-- Hover versus layout (visible on hover) -->
<div class="hover-versus-layout" :class="getHoveredSideForMatch(match, `upcoming-${match.id}`)">
<div class="hover-team home-slide">
<TeamEmblem
size="md"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="hover-team-name">{{ match.homeTeamName }}</span>
</div>
<div class="hover-vs-glow">VS</div>
<div class="hover-team away-slide">
<TeamEmblem
size="md"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="hover-team-name">{{ match.awayTeamName }}</span>
</div>
</div>
</div>
<div class="match-market-side">
<div class="market-tabs">
<button
v-for="m in getAvailableMarketsForMatch(match)"
:key="m.marketType"
type="button"
class="market-tab-btn"
:class="{ active: getActiveMarketType(match) === m.marketType }"
@click="setActiveMarketType(match.id, m.marketType)"
>
{{ getMarketTabLabel(m.marketType) }}
</button>
</div>
<div class="market-odds-area">
<template v-if="getActiveMarket(match)">
<MarketSelectionsPanel
desktop-card
compact
:locked="isMarketLocked(match, getActiveMarket(match))"
:line-value="getActiveMarket(match)?.lineValue"
:selections="getActiveMarket(match)?.selections || []"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(match, getActiveMarket(match)!, selId, ev)"
/>
</template>
<div v-else class="no-market-odds">
{{ t('bet.no_market_odds') || '暂无盘口赔率' }}
</div>
</div>
</div>
</div>
</div>
<div v-else-if="!loading" class="empty-state">
<p>{{ t('home.upcoming_empty') }}</p>
</div>
</section>
</div>
<!-- 右侧多卡边栏 -->
<aside class="home-sidebar anim-fade-up" style="--anim-delay:120ms">
<HomeAnnouncementCard />
<div v-if="sideRecommendMatches.length" class="side-card">
<div class="side-card-header">
<span class="side-card-title">{{ t('home.sidebar_recommend') }}</span>
</div>
<ul class="side-recommend-list">
<li
v-for="(m, idx) in sideRecommendMatches"
:key="m.id"
class="side-recommend-item anim-fade-up"
:class="{ 'side-recommend-item--engaged': isActiveForCard(`sidebar-${m.id}`) }"
:data-bet-card-root="`sidebar-${m.id}`"
:style="{ '--anim-delay': (200 + idx * 70) + 'ms' }"
@click="goMatch(m.id)"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="sri-top-row">
<span class="sri-league">{{ m.leagueName }}</span>
<span class="sri-time">{{ formatKickoff(m.startTime) }}</span>
</div>
<div class="sri-teams">
<span>{{ m.homeTeamName }}</span>
<span class="sri-vs">vs</span>
<span>{{ m.awayTeamName }}</span>
</div>
<!-- 悬浮预览卡 -->
<div class="sri-preview-card" @click.stop @mouseenter="cancelClose" @mouseleave="scheduleClose()">
<div class="sri-preview-top">
<span class="sri-preview-league">{{ m.leagueName }}</span>
<span class="sri-preview-time">{{ formatKickoff(m.startTime) }}</span>
</div>
<div class="sri-preview-vs" :class="getHoveredSideForMatch(m, `sidebar-${m.id}`)">
<div class="sri-preview-team home-team">
<TeamEmblem size="md" :team-code="m.homeTeamCode" :team-name="m.homeTeamName" :logo-url="m.homeTeamLogoUrl" />
<span>{{ m.homeTeamName }}</span>
</div>
<div class="sri-preview-vs-text">VS</div>
<div class="sri-preview-team away-team">
<TeamEmblem size="md" :team-code="m.awayTeamCode" :team-name="m.awayTeamName" :logo-url="m.awayTeamLogoUrl" />
<span>{{ m.awayTeamName }}</span>
</div>
</div>
<div v-if="getActiveMarket(m)" class="sri-preview-odds">
<div
v-for="sel in (getActiveMarket(m)?.selections || []).slice(0, 3)"
:key="sel.id"
class="sri-preview-odd-btn"
:class="{ selected: isSelected(sel.id) }"
@click.stop="handleOddsClick(m, getActiveMarket(m)!, sel.id, $event)"
>
<span class="sri-odd-label">{{ getSelLabel(sel, getActiveMarket(m)?.marketType || '', getActiveMarket(m)?.lineValue) }}</span>
<span class="sri-odd-val">{{ sel.odds }}</span>
</div>
</div>
<div v-else class="sri-preview-no-odds">暂无盘口</div>
</div>
</li>
</ul>
</div>
</aside>
</div>
</div>
</template>
<style scoped>
.desktop-home-view {
display: flex;
flex-direction: column;
gap: var(--space-4);
width: 100%;
}
.hero-row {
display: block;
width: 100%;
}
.hero-banner-full {
min-width: 0;
}
.qe-icon {
font-size: 24px;
line-height: 1;
}
.qe-label {
font-size: 12px;
font-weight: 700;
line-height: 1.2;
text-align: center;
}
/* [C] 跑马灯金条 */
.home-marquee-strip {
position: relative;
height: 34px;
border-radius: var(--radius-md);
overflow: hidden;
background: linear-gradient(90deg,
rgba(212, 175, 55, 0.02),
rgba(212, 175, 55, 0.14),
rgba(212, 175, 55, 0.02));
border: 1px solid var(--border-gold-soft);
display: flex;
align-items: stretch;
padding: 0;
box-shadow: inset 0 0 12px rgba(212, 175, 55, 0.08);
backdrop-filter: var(--blur-sm);
-webkit-backdrop-filter: var(--blur-sm);
}
/* force the embedded button to fill the strip completely */
.home-marquee-strip :deep(.marquee-bar.embedded) {
width: 100%;
height: 100%;
margin: 0;
padding: 0 12px;
border: none;
border-radius: 0;
background: transparent;
}
/* [D] Main Row */
.main-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: var(--space-4);
align-items: start;
}
.main-col {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.content-section {
background: rgba(20, 20, 20, 0.88);
backdrop-filter: var(--blur-md);
-webkit-backdrop-filter: var(--blur-md);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-4) 18px;
box-shadow: var(--shadow-sm);
}
/* section-title 金线 */
.section-title {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.st-line {
width: 4px;
height: 18px;
border-radius: 2px;
background: linear-gradient(180deg, var(--primary), var(--primary-light));
box-shadow: 0 0 8px rgba(212, 175, 55, 0.6);
}
.st-text {
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 400;
color: var(--text);
letter-spacing: 0.5px;
}
.st-refresh {
margin-left: auto;
background: transparent;
color: var(--primary-light);
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.st-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spin {
display: inline-block;
animation: spin 1s linear infinite;
}
@keyframes spin {
100% { transform: rotate(360deg); }
}
.loading-state,
.empty-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 160px;
color: var(--text-muted);
}
/* Featured 焦点大卡 */
.featured-match {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: 18px 20px;
border-radius: var(--radius-lg);
background:
linear-gradient(135deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.04) 50%, rgba(15, 10, 0, 0.1)),
url('../../assets/images/card-bg.webp') center/cover no-repeat;
border: 1px solid var(--border-gold);
box-shadow: var(--shadow-gold-lg), inset 0 0 20px rgba(212, 175, 55, 0.06);
margin-bottom: var(--space-4);
cursor: pointer;
transition: box-shadow var(--duration-smooth) var(--ease-smooth),
transform var(--duration-normal) var(--ease-out-expo);
position: relative;
overflow: hidden;
}
.featured-match::before {
content: '';
position: absolute;
top: 0; right: 0;
width: 180px; height: 180px;
background: radial-gradient(circle at top right, rgba(212, 175, 55, 0.2), transparent 60%);
pointer-events: none;
animation: goldGlowPulse 3.5s ease-in-out infinite;
transform-origin: top right;
}
.featured-match:hover {
transform: translateY(-2px);
box-shadow: var(--glow-gold-lg), inset 0 0 24px rgba(212, 175, 55, 0.08);
}
.featured-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.featured-meta {
display: flex;
align-items: center;
gap: 10px;
}
.league-tag.lg {
font-size: 12px;
padding: 3px 8px;
max-width: 480px;
}
.live-indicator.lg {
font-size: 11px;
padding: 3px 6px;
}
.featured-time {
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
}
.featured-teams {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
padding: 10px 0;
}
.featured-team {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
min-width: 0;
}
.featured-team-name {
font-size: 18px;
font-weight: 800;
color: var(--text);
text-align: center;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.featured-vs {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 400;
font-style: italic;
color: var(--primary-light);
text-shadow: 0 0 12px rgba(212, 175, 55, 0.5);
}
.featured-markets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin: 14px -20px -18px;
padding: 14px 20px 18px;
background: #000000;
border-top: 1px solid rgba(212, 175, 55, 0.2);
}
.featured-market-block {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.featured-market-label {
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 400;
color: #ffffff;
text-align: center;
letter-spacing: 0.4px;
text-transform: uppercase;
}
/* 普通赛事卡片 grid2 列) */
.matches-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 12px;
}
.match-card {
display: flex;
background: var(--bg-card);
backdrop-filter: var(--blur-sm);
-webkit-backdrop-filter: var(--blur-sm);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color var(--duration-normal) var(--ease-smooth),
box-shadow var(--duration-normal) var(--ease-smooth),
transform var(--duration-normal) var(--ease-out-expo);
min-height: 76px;
}
.match-card:is(:hover, .match-card--engaged) {
border-color: var(--border-gold);
box-shadow: var(--shadow-gold-lg), var(--glow-gold);
transform: translateY(-2px);
}
.match-info-side {
flex: 1;
padding: var(--space-2) var(--space-3);
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--space-1);
border-right: 1px solid var(--border);
min-width: 0;
cursor: pointer;
background: rgba(255, 255, 255, 0.01);
position: relative;
overflow: hidden;
transition: background var(--duration-fast) var(--ease-smooth),
box-shadow var(--duration-normal) var(--ease-smooth);
}
.match-info-side > * {
position: relative;
z-index: 1;
}
.match-card:is(:hover, .match-card--engaged) .match-info-side {
background: rgba(212, 175, 55, 0.03);
box-shadow: inset 3px 0 0 var(--primary);
}
.match-market-side {
width: 260px;
flex-shrink: 0;
padding: 8px 10px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
background: var(--bg-card);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.top-meta {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.league-tag {
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 700;
color: var(--primary-light);
background: var(--border-gold-soft);
padding: 1px 6px;
border-radius: var(--radius-sm);
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
flex: 1;
min-width: 0;
line-height: 1.2;
}
.live-indicator {
font-family: var(--font-display);
font-size: 9px;
font-weight: 400;
background: var(--danger);
color: #fff;
padding: 1px 3px;
border-radius: var(--radius-sm);
line-height: 1;
}
.match-time {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.teams-versus-compact {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.vs-text {
font-size: 10px;
font-weight: 800;
color: var(--text-muted);
font-style: italic;
padding-left: 36px;
line-height: 1;
margin: -2px 0;
}
.team-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.team-name {
font-size: var(--text-sm);
font-weight: 700;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.market-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
padding-bottom: 4px;
margin-bottom: 4px;
}
.market-tab-btn {
padding: 2px 6px;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 700;
border-radius: var(--radius);
background: transparent;
color: var(--text-muted);
border: none;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-smooth),
color var(--duration-fast) var(--ease-smooth);
white-space: nowrap;
}
.market-tab-btn:hover {
background: var(--bg-hover);
color: var(--primary-light);
}
.market-tab-btn.active {
background: var(--border-gold-soft);
color: var(--primary-light);
}
.market-tab-btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
.market-odds-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.no-market-odds {
font-size: var(--text-xs);
color: var(--text-muted);
text-align: center;
}
/* 右侧栏 */
.home-sidebar {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.home-sidebar :deep(.home-announce-card) {
border-radius: var(--radius-lg);
}
.side-card {
background: rgba(20, 20, 20, 0.88);
backdrop-filter: var(--blur-md);
-webkit-backdrop-filter: var(--blur-md);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-3) 14px;
box-shadow: var(--shadow-sm);
}
.side-card-header {
display: flex;
align-items: center;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.side-card-title {
font-family: var(--font-display);
font-size: var(--text-base);
font-weight: 400;
color: var(--primary-light);
letter-spacing: 0.3px;
}
.side-recommend-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.side-recommend-item {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-smooth),
transform var(--duration-fast) var(--ease-out-expo),
box-shadow var(--duration-fast) var(--ease-smooth);
}
.side-recommend-item:hover {
background: rgba(212, 175, 55, 0.06);
transform: translateX(3px);
box-shadow: inset 2px 0 0 var(--primary);
}
.sri-top-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 4px;
}
.sri-league {
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
background: var(--border-gold-soft);
padding: 1px 4px;
border-radius: 3px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sri-teams {
font-size: 11px;
font-weight: 700;
color: var(--text);
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sri-vs {
color: var(--text-muted);
font-size: 9px;
font-weight: 600;
font-style: italic;
}
.sri-time {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
}
/* @media 单列回退 */
@media (max-width: 1100px) {
.main-row {
grid-template-columns: minmax(0, 1fr);
}
.matches-grid {
grid-template-columns: minmax(0, 1fr);
}
.featured-markets {
grid-template-columns: minmax(0, 1fr);
}
.featured-team-name {
font-size: 15px;
}
}
/* ============================================================
✨ ANIMATION SYSTEM
============================================================ */
/* --- 1. 通用入场fade + slide-up --- */
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.anim-fade-up {
opacity: 0;
animation: fadeSlideUp 0.55s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: var(--anim-delay, 0ms);
}
/* --- 2. 黄金光晕呼吸脉冲(焦点大卡 ::before --- */
@keyframes goldGlowPulse {
0%, 100% {
opacity: 0.18;
transform: scale(1);
}
50% {
opacity: 0.32;
transform: scale(1.15);
}
}
/* --- 3. VS 心跳 --- */
@keyframes vsHeartbeat {
0%, 100% { transform: scale(1); }
14% { transform: scale(1.12); }
28% { transform: scale(1); }
42% { transform: scale(1.06); }
56% { transform: scale(1); }
}
.vs-heartbeat {
animation: vsHeartbeat 2.8s ease-in-out infinite;
display: inline-block;
}
/* --- 4. LIVE 脉冲闪烁 --- */
@keyframes livePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.7); }
50% { box-shadow: 0 0 0 5px rgba(220, 38, 38, 0); }
}
.live-pulse {
animation: livePulse 1.6s ease-out infinite;
}
/* --- 5. section 标题金线滑入 --- */
@keyframes lineSlideIn {
from { transform: scaleY(0); opacity: 0; }
to { transform: scaleY(1); opacity: 1; }
}
.st-line-animated {
transform-origin: top center;
animation: lineSlideIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s both;
}
/* --- 6. 赛事卡片:鼠标移动到卡片上时两只队伍居中且出现 VS 经典动画 --- */
.default-info-layout {
width: 100%;
transition: opacity 0.35s cubic-bezier(0.25, 0.8, 0.25, 1), transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.match-card:is(:hover, .match-card--engaged) .default-info-layout {
opacity: 0;
transform: translateY(-8px) scale(0.95);
pointer-events: none;
}
.hover-versus-layout {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
padding: 8px 16px;
opacity: 0;
transform: translateY(8px) scale(0.95);
transition: opacity 0.35s cubic-bezier(0.25, 0.8, 0.25, 1), transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
background: rgba(14, 14, 14, 0.96);
border-radius: 8px 0 0 8px;
}
.match-card:is(:hover, .match-card--engaged) .hover-versus-layout {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.hover-team.home-slide {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
transform: translateX(-35px);
opacity: 0;
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.05s, opacity 0.4s ease 0.05s;
min-width: 0;
flex: 1;
}
.match-card:is(:hover, .match-card--engaged) .hover-team.home-slide {
transform: translateX(0);
opacity: 1;
}
.hover-team.away-slide {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
transform: translateX(35px);
opacity: 0;
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.05s, opacity 0.4s ease 0.05s;
min-width: 0;
flex: 1;
}
.match-card:is(:hover, .match-card--engaged) .hover-team.away-slide {
transform: translateX(0);
opacity: 1;
}
.hover-team-name {
font-size: 11px;
font-weight: 700;
color: var(--text);
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hover-vs-glow {
font-size: 18px;
font-weight: 900;
font-style: italic;
color: var(--primary-light);
transform: scale(2.8);
opacity: 0;
text-shadow:
0 0 10px rgba(212, 175, 55, 0.8),
0 0 20px rgba(212, 175, 55, 0.4);
transition: transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s, opacity 0.3s ease 0.1s;
flex-shrink: 0;
}
.match-card:is(:hover, .match-card--engaged) .hover-vs-glow {
transform: scale(1);
opacity: 1;
animation: vs-clash-impact 0.3s ease-out 0.35s;
}
@keyframes vs-clash-impact {
0% {
text-shadow: 0 0 25px rgba(212, 175, 55, 1);
transform: scale(1.25);
}
100% {
text-shadow: 0 0 8px rgba(212, 175, 55, 0.7);
transform: scale(1);
}
}
/* --- 7. 侧栏卡片整体 hover 上浮 --- */
.side-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.side-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(212, 175, 55, 0.12);
}
/* --- 8. 推荐列表 item 左侧金点指示 hover --- */
.side-recommend-item {
position: relative;
transition: background 0.18s, transform 0.18s;
}
.side-recommend-item:hover {
background: rgba(212, 175, 55, 0.06);
transform: translateX(3px);
}
/* 鼠标移动通道桥接桥防止在间隙处丢失hover状态高度限制为推荐项高度防止重叠错误 */
.side-recommend-item::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -20px;
width: 20px;
background: transparent;
pointer-events: auto;
}
/* --- 9. 推荐赛事悬浮预览卡 --- */
.sri-preview-card {
position: absolute;
top: 50%;
right: calc(100% + 6px);
transform: translateY(-50%) translateX(8px);
width: 260px;
background: rgba(12, 12, 12, 0.97);
backdrop-filter: var(--blur-lg);
-webkit-backdrop-filter: var(--blur-lg);
border: 1px solid var(--border-gold);
border-radius: var(--radius-lg);
padding: var(--space-3) 14px;
box-shadow: var(--shadow-lg), var(--glow-gold-md);
opacity: 0;
pointer-events: none;
transition: opacity var(--duration-smooth) var(--ease-smooth),
transform var(--duration-smooth) var(--ease-out-expo);
z-index: var(--z-popover);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.side-recommend-item:is(:hover, .side-recommend-item--engaged) .sri-preview-card {
opacity: 1;
transform: translateY(-50%) translateX(0);
pointer-events: auto;
}
.sri-preview-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.sri-preview-league {
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
background: var(--border-gold-soft);
padding: 2px 6px;
border-radius: 4px;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sri-preview-time {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
}
.sri-preview-vs {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sri-preview-team {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
flex: 1;
min-width: 0;
opacity: 0;
transition: opacity 0.35s ease, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.sri-preview-team.home-team {
transform: translateX(-24px);
}
.sri-preview-team.away-team {
transform: translateX(24px);
}
.side-recommend-item:hover .sri-preview-team {
opacity: 1;
transform: translateX(0);
}
.sri-preview-team span {
font-size: 11px;
font-weight: 700;
color: var(--text);
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sri-preview-vs-text {
font-size: 16px;
font-weight: 900;
font-style: italic;
color: var(--primary-light);
text-shadow: 0 0 8px rgba(212, 175, 55, 0.6);
flex-shrink: 0;
opacity: 0;
transform: scale(2.2);
transition: opacity 0.3s ease 0.1s, transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s;
}
.side-recommend-item:hover .sri-preview-vs-text {
opacity: 1;
transform: scale(1);
}
.sri-preview-odds {
display: flex;
gap: 5px;
}
.sri-preview-odd-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 5px 4px;
border-radius: 6px;
background: var(--bg-hover);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.sri-preview-odd-btn:hover {
background: var(--border-gold-soft);
border-color: var(--border-gold);
}
.sri-preview-odd-btn.selected {
background: var(--border-gold-soft);
border-color: var(--primary-light);
}
.sri-odd-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
}
.sri-odd-val {
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
}
.sri-preview-no-odds {
text-align: center;
font-size: 10px;
color: var(--text-muted);
}
/* --- Sri preview: team highlight on odds selection --- */
.sri-preview-team {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, filter 0.3s ease;
}
.sri-preview-vs.home .sri-preview-team.home-team {
transform: translateX(0) scale(1.1) !important;
filter: brightness(1.3) saturate(1.2);
opacity: 1 !important;
}
.sri-preview-vs.home .sri-preview-team.away-team {
opacity: 0.35 !important;
transform: translateX(0) scale(0.92) !important;
filter: grayscale(0.6) brightness(0.7);
}
.sri-preview-vs.away .sri-preview-team.away-team {
transform: translateX(0) scale(1.1) !important;
filter: brightness(1.3) saturate(1.2);
opacity: 1 !important;
}
.sri-preview-vs.away .sri-preview-team.home-team {
opacity: 0.35 !important;
transform: translateX(0) scale(0.92) !important;
filter: grayscale(0.6) brightness(0.7);
}
.sri-preview-vs.draw .sri-preview-team {
transform: translateX(0) scale(1.05) !important;
filter: brightness(1.15) saturate(1.1);
opacity: 1 !important;
}
/* --- VS Selection Highlight: scale selected team, no VS animation --- */
.featured-team,
.hover-team {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, filter 0.3s ease;
}
/* featured match (home) */
.featured-teams.home .featured-team:first-child {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
}
.featured-teams.home .featured-team:last-child {
opacity: 0.35;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
/* featured match (away) */
.featured-teams.away .featured-team:last-child {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
}
.featured-teams.away .featured-team:first-child {
opacity: 0.35;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
/* featured match (draw) */
.featured-teams.draw .featured-team {
transform: scale(1.05);
filter: brightness(1.15) saturate(1.1);
}
/* hover-versus-layout (sidebar/upcoming) */
.hover-versus-layout.home .hover-team.home-slide {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
z-index: 2;
}
.hover-versus-layout.home .hover-team.away-slide {
opacity: 0.35 !important;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
.hover-versus-layout.away .hover-team.away-slide {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
z-index: 2;
}
.hover-versus-layout.away .hover-team.home-slide {
opacity: 0.35 !important;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
.hover-versus-layout.draw .hover-team.home-slide,
.hover-versus-layout.draw .hover-team.away-slide {
transform: scale(1.05);
filter: brightness(1.15) saturate(1.1);
}
</style>