Files
thebet365/apps/player/src/components/MatchBetCard.vue
mars b02e79785e feat(player): refresh dark gold betting theme
- add generated dark-gold WebP assets for PC/mobile backgrounds, cards, dialogs, tabs, mailbox, VS and bet buttons
- apply the new assets across player home, match detail, outright cards, modals, wallet/profile/detail screens and bet slip controls
- simplify confirm dialogs and reuse bet button resources for normal/pressed states
- hide mobile announcement center, tighten mobile bet list layout, and remove extra dark page backgrounds/scrollbar styling
- cache and deduplicate outright market loading to speed up repeated tab/detail navigation
- document player image-generation constraints in AGENTS.md
2026-07-23 17:34:40 +08:00

681 lines
18 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 { ref, computed, getCurrentInstance } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { teamFlagUrl } from '../utils/teamFlag';
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import { useBetSlipStore } from '../stores/betSlip';
import { useDesktopBetPopover } from '../composables/useDesktopBetPopover';
import { resolveSelectionLabel } from '../utils/selectionLabel';
import MarketSelectionsPanel from './match-detail/MarketSelectionsPanel.vue';
import vsImg from '../assets/images/generated/vs-gold-alpha-v1.webp';
import matchListCardBg from '../assets/images/generated/pc-match-list-card-bg-v1.webp';
const matchCardBg = `url(${matchListCardBg})`;
const props = defineProps<{
match: {
id: string;
homeTeamName: string;
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamLogoUrl?: string | null;
awayTeamLogoUrl?: string | null;
startTime: string;
bettingOpen?: boolean;
matchPhase?: MatchPhase;
score?: {
htHome: number | null;
htAway: number | null;
ftHome: number | null;
ftAway: number | null;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
} | null;
markets?: any[];
};
noQuickBet?: boolean;
}>();
const emit = defineEmits<{ bet: [id: string] }>();
const { t, locale } = useI18n();
function formatKickoff(startTime: string) {
return formatLocalMatchDateTime(startTime, locale.value, {
variant: 'compact',
todayLabel: t('bet.today'),
});
}
const kickoffText = computed(() => formatKickoff(props.match.startTime));
const homeFlagUrl = computed(() =>
teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName, props.match.homeTeamLogoUrl),
);
const awayFlagUrl = computed(() =>
teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName, props.match.awayTeamLogoUrl),
);
const homeIsLogo = computed(() => Boolean(props.match.homeTeamLogoUrl?.trim()));
const awayIsLogo = computed(() => Boolean(props.match.awayTeamLogoUrl?.trim()));
const bettingOpen = computed(() => props.match.bettingOpen !== false);
const phase = computed(
() => props.match.matchPhase ?? (bettingOpen.value ? 'open' : 'closed_pending'),
);
const phaseLabel = computed(() => matchPhaseLabel(t, phase.value));
const liveScoreText = computed(() => {
const s = props.match.score;
if (!s || s.ftHome == null || s.ftAway == null) return '';
return `${s.ftHome} - ${s.ftAway}`;
});
const router = useRouter();
const slip = useBetSlipStore();
const { openAt, visible: popoverVisible, pendingItem, cancelClose, scheduleClose, isActiveForCard } = useDesktopBetPopover();
const cardRootId = `bet-card-${props.match.id}-${getCurrentInstance()?.uid ?? 0}`;
const isCardEngaged = computed(() => isActiveForCard(cardRootId));
function getHoveredSide() {
if (!popoverVisible.value || !pendingItem.value) return '';
if (!isActiveForCard(cardRootId)) return '';
if (String(pendingItem.value.matchId) !== String(props.match.id)) return '';
const selId = pendingItem.value.selectionId;
// Scan all markets to find the selection and get its code
for (const market of props.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';
// Over/Under: over = home side, under = away side (for visual cue)
if (code === 'OVER') return 'home';
if (code === 'UNDER') return 'away';
// Odd/Even: highlight both
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 '';
}
const activeMarketType = ref<string>('');
const currentMarketType = computed(() => {
if (activeMarketType.value) return activeMarketType.value;
const available = getAvailableMarketsForMatch(props.match);
if (available.some((m: any) => m.marketType === 'FT_1X2')) {
return 'FT_1X2';
}
return available[0]?.marketType || 'FT_1X2';
});
function isSelected(id: string) {
return slip.isInSlip(id);
}
function getAvailableMarketsForMatch(match: any) {
const supported = ['FT_1X2', 'FT_HANDICAP', 'FT_OVER_UNDER'];
return (match.markets ?? []).filter((m: any) => supported.includes(m.marketType));
}
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 getSelLabel(sel: any, marketType: string, lineValue?: string | number | null) {
return resolveSelectionLabel(t, sel.selectionCode || '', sel.selectionName || '', { lineValue });
}
function handleOddsClick(match: any, 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: getSelLabel(sel, market.marketType, 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 isMarketLocked(match: any, 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 getActiveMarket() {
const type = currentMarketType.value;
return props.match.markets?.find((m: any) => m.marketType === type);
}
function goMatchDetail() {
router.push(`/match/${props.match.id}`);
}
</script>
<template>
<article
class="match-card"
:data-bet-card-root="cardRootId"
:class="{
'match-card--phase': phase !== 'open',
'no-quick-bet': noQuickBet,
'match-card--engaged': isCardEngaged,
}"
@click="emit('bet', match.id)"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<span v-if="phase === 'open'" class="status-tag status-tag--open">{{ t('bet.status_open') }}</span>
<span v-else-if="phase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
<span v-else class="status-tag status-tag--pending">{{ phaseLabel }}</span>
<div class="teams-row" :class="getHoveredSide()">
<div class="team">
<span class="team-name">{{ match.homeTeamName }}</span>
<img
v-if="homeFlagUrl"
:src="homeFlagUrl"
class="team-flag"
:class="{ 'flag-logo': homeIsLogo }"
alt=""
loading="lazy"
decoding="async"
/>
</div>
<div class="center-col">
<span class="kickoff">{{ kickoffText }}</span>
<span v-if="phase !== 'open' && liveScoreText" class="live-score">{{ liveScoreText }}</span>
<span v-else class="vs">
<img :src="vsImg" alt="VS" class="vs-img" />
</span>
</div>
<div class="team">
<span class="team-name">{{ match.awayTeamName }}</span>
<img
v-if="awayFlagUrl"
:src="awayFlagUrl"
class="team-flag"
:class="{ 'flag-logo': awayIsLogo }"
alt=""
loading="lazy"
decoding="async"
/>
</div>
</div>
<button
type="button"
class="bet-btn"
:class="bettingOpen ? 'btn-gold-outline' : 'bet-btn--view'"
@click.stop="emit('bet', match.id)"
>
{{ bettingOpen ? t('bet.place_bet_short') : t('bet.view_match') }}
</button>
<!-- Morphing Quick Bet panel (Desktop Only, disabled when noQuickBet=true) -->
<div v-if="!noQuickBet" class="card-quick-bet" @click.stop>
<div class="quick-bet-tabs" v-if="getAvailableMarketsForMatch(match).length">
<button
v-for="m in getAvailableMarketsForMatch(match)"
:key="m.marketType"
type="button"
class="quick-bet-tab-btn"
:class="{ active: currentMarketType === m.marketType }"
@click="activeMarketType = m.marketType"
>
{{ getMarketTabLabel(m.marketType) }}
</button>
<span class="quick-bet-all-link" @click="goMatchDetail">{{ t('bet.all') || '全部' }} </span>
</div>
<div class="quick-bet-odds">
<template v-if="getActiveMarket()">
<MarketSelectionsPanel
desktop-card
compact
:locked="isMarketLocked(match, getActiveMarket())"
:line-value="getActiveMarket()?.lineValue"
:selections="getActiveMarket()?.selections || []"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(match, getActiveMarket()!, selId, ev)"
/>
</template>
<div v-else class="quick-bet-no-odds">
{{ t('bet.no_market_odds') || '暂无盘口' }}
</div>
</div>
</div>
</article>
</template>
<style scoped>
.match-card {
position: relative;
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
border: 0;
border-radius: 0;
padding: 10px 8px 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 130px;
overflow: hidden;
cursor: pointer;
}
.match-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.2));
pointer-events: none;
z-index: 0;
}
.match-card--phase {
/* inherits base dark style */
}
.teams-row {
position: relative;
z-index: 2;
display: flex;
align-items: center;
width: 100%;
gap: 4px;
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), padding 0.3s ease;
}
.team {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 0;
transform: translateY(8px);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.team-name {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 900;
color: #fff;
line-height: 1.2;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.95);
word-break: break-word;
text-align: center;
padding: 0 6px;
align-self: stretch;
transition: font-size 0.25s ease;
}
.match-card--phase .team-name {
color: #d8d8d8;
}
.team-flag {
width: 72px;
height: 48px;
object-fit: cover;
border-radius: 3px;
display: block;
transition: transform 0.25s ease, opacity 0.2s ease, height 0.25s ease, width 0.25s ease, margin 0.25s ease;
}
.team-flag.flag-logo {
width: 48px;
height: 48px;
object-fit: contain;
transition: transform 0.25s ease, opacity 0.2s ease, height 0.25s ease, width 0.25s ease, margin 0.25s ease;
}
.center-col {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 56px;
}
.kickoff {
font-size: 13px;
font-weight: 700;
color: #fff;
line-height: 1.15;
white-space: normal;
text-align: center;
max-width: 96px;
overflow-wrap: anywhere;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
transition: opacity 0.2s ease, height 0.2s ease, margin 0.2s ease;
}
.live-score {
font-size: 20px;
font-weight: 900;
color: #fff;
line-height: 1;
letter-spacing: 0.04em;
transition: font-size 0.25s ease;
}
.vs {
width: 48px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.3s ease;
}
.vs-img {
width: 46px;
max-width: 100%;
height: auto;
object-fit: contain;
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.9));
}
.status-tag {
position: absolute;
top: 0;
right: 0;
z-index: 3;
font-size: 10px;
font-weight: 800;
padding: 3px 10px;
border-radius: 0 6px 0 8px;
line-height: 1.3;
white-space: nowrap;
letter-spacing: 0.02em;
}
.status-tag--open {
background: linear-gradient(135deg, #e8c84a, #d4a017);
color: #1a1a1a;
box-shadow: 0 2px 6px rgba(212, 160, 23, 0.4);
}
.status-tag--settled {
background: linear-gradient(180deg, #2a2a3a 0%, #1a1a28 100%);
color: #8a9ab8;
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
border-left: 1px solid rgba(120, 140, 180, 0.3);
}
.status-tag--pending {
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
color: #c8a84e;
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
border-left: 1px solid rgba(200, 168, 78, 0.3);
}
.bet-btn {
position: relative;
z-index: 2;
width: auto;
min-width: 80px;
padding: 5px 24px;
border-radius: 6px;
font-size: 13px;
letter-spacing: 0.04em;
line-height: 1.2;
overflow: hidden;
transition: opacity 0.2s ease, transform 0.2s ease, height 0.25s ease, padding 0.25s ease, min-width 0.25s ease, min-height 0.25s ease, margin 0.25s ease;
}
.bet-btn--view {
background: #1a1a1a;
border: 1px solid #444;
color: #aaa;
}
/* --- Morphing Quick Bet panel --- */
.card-quick-bet {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
pointer-events: none;
transform: translateY(12px);
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 5;
}
@media (hover: hover) {
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) {
border-color: var(--border-gold);
box-shadow: var(--shadow-gold);
}
/* 整行保持原位,间距自然 */
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .teams-row {
transform: translateY(0);
padding: 0 40px;
}
/* .team 取消自身 translateY */
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team {
transform: translateY(0);
gap: 3px;
}
/* 旗帜缩小到 40×28 */
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team-flag {
width: 40px;
height: 28px;
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team-flag.flag-logo {
width: 28px;
height: 28px;
}
/* 队名缩小并禁止换行 */
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team-name {
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* 隐藏开赛时间 */
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .kickoff {
opacity: 0;
height: 0;
margin: 0;
overflow: hidden;
pointer-events: none;
}
/* VS / 比分 */
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .vs,
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .live-score {
font-size: 16px;
}
/* 下注按钮淡出(保留布局空间避免高度抖动) */
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .bet-btn {
opacity: 0;
pointer-events: none;
}
/* 快速下注面板淡入 */
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .card-quick-bet {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
/* sidebar 模式hover 仍有金边,但不做复杂动画 */
.match-card.no-quick-bet:hover {
border-color: var(--border-gold);
box-shadow: var(--shadow-gold);
}
}
.quick-bet-tabs {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
padding-bottom: 4px;
margin-bottom: 2px;
}
.quick-bet-tab-btn {
padding: 2px 6px;
font-size: 9px;
font-weight: 700;
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
border-radius: 3px;
transition: all 0.15s;
}
.quick-bet-tab-btn:hover {
color: var(--primary-light);
}
.quick-bet-tab-btn.active {
color: var(--primary-light);
background: var(--border-gold-soft);
}
.quick-bet-all-link {
margin-left: auto;
font-size: 9px;
font-weight: 700;
color: var(--primary-light);
cursor: pointer;
padding: 2px 4px;
transition: opacity 0.15s;
}
.quick-bet-all-link:hover {
text-shadow: 0 0 4px rgba(212, 175, 55, 0.6);
}
.quick-bet-odds {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.quick-bet-odds :deep(.odds-btn) {
min-height: 26px !important;
padding: 1px 4px !important;
gap: 1px !important;
}
.quick-bet-odds :deep(.odds) {
font-size: 11px !important;
}
.quick-bet-odds :deep(.label) {
font-size: 8px !important;
}
.quick-bet-no-odds {
font-size: 10px;
color: var(--text-muted);
}
/* --- VS Selection Highlight: scale selected team, no VS animation --- */
.teams-row .team {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, filter 0.3s ease;
}
.teams-row.home .team:first-child {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
z-index: 2;
}
.teams-row.home .team:last-child {
opacity: 0.35 !important;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
.teams-row.away .team:last-child {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
z-index: 2;
}
.teams-row.away .team:first-child {
opacity: 0.35 !important;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
.teams-row.draw .team {
transform: scale(1.05);
filter: brightness(1.15) saturate(1.1);
}
</style>