1. 后台/API:赛事详情返回 allowSingle/allowParlay,管理端 mapMarkets 正确回读 2. 玩家端:仅关单关时仍可点选加入串关;投注抽屉禁用单关提交并提示 3. 玩家端:盘口暂停/已关闭状态标签;MarketTypeTile 展示 statusLabel Co-authored-by: Cursor <cursoragent@cursor.com>
990 lines
25 KiB
Vue
990 lines
25 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed } from 'vue';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { useI18n } from 'vue-i18n';
|
|
import api from '../api';
|
|
import { formatMoney } from '../utils/localeDisplay';
|
|
import { useBetSlipStore } from '../stores/betSlip';
|
|
import { useAuthStore } from '../stores/auth';
|
|
import TeamEmblem from '../components/TeamEmblem.vue';
|
|
import MatchBetGuide from '../components/match-detail/MatchBetGuide.vue';
|
|
import MarketTypeTile from '../components/match-detail/MarketTypeTile.vue';
|
|
import MarketSelectionsPanel from '../components/match-detail/MarketSelectionsPanel.vue';
|
|
import CorrectScorePanel from '../components/match-detail/CorrectScorePanel.vue';
|
|
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
|
|
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
|
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
|
import vsImg from '../assets/images/vs.png';
|
|
import GoldSpinner from '../components/GoldSpinner.vue';
|
|
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
|
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const { t, locale } = useI18n();
|
|
const slip = useBetSlipStore();
|
|
const auth = useAuthStore();
|
|
|
|
function goLogin() {
|
|
auth.showLoginPrompt(route.fullPath);
|
|
}
|
|
|
|
interface Market {
|
|
id: string;
|
|
marketType: string;
|
|
marketDisplayName?: string;
|
|
lineKey?: string | null;
|
|
period: string;
|
|
lineValue?: string | number | null;
|
|
status?: string;
|
|
allowSingle?: boolean;
|
|
allowParlay?: boolean;
|
|
promoLabel?: string | null;
|
|
selections: Selection[];
|
|
}
|
|
|
|
interface Selection {
|
|
id: string;
|
|
selectionCode: string;
|
|
selectionName: string;
|
|
selectionDisplayName?: string;
|
|
odds: string;
|
|
oddsVersion: string;
|
|
}
|
|
|
|
interface MatchDetail {
|
|
id: string;
|
|
leagueName?: string;
|
|
leagueLogoUrl?: string | null;
|
|
homeTeamName: string;
|
|
awayTeamName: string;
|
|
homeTeamCode?: string;
|
|
awayTeamCode?: string;
|
|
homeTeamLogoUrl?: string | null;
|
|
awayTeamLogoUrl?: string | null;
|
|
startTime: string;
|
|
stage?: string | null;
|
|
groupName?: string | null;
|
|
status?: 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: Market[];
|
|
}
|
|
|
|
const match = ref<MatchDetail | null>(null);
|
|
const loading = ref(true);
|
|
|
|
interface MyBet {
|
|
betNo: string;
|
|
betType: string;
|
|
stake: string;
|
|
totalOdds: string;
|
|
potentialReturn: string;
|
|
actualReturn: string;
|
|
status: string;
|
|
placedAt: string;
|
|
pickLabel: string;
|
|
matchTitle: string;
|
|
}
|
|
const myBets = ref<MyBet[]>([]);
|
|
const loadingMyBets = ref(false);
|
|
|
|
async function loadMyBets() {
|
|
if (!match.value || !auth.token) return;
|
|
loadingMyBets.value = true;
|
|
try {
|
|
const { data } = await api.get('/player/bets', {
|
|
params: { page: 1, matchId: match.value.id },
|
|
});
|
|
myBets.value = (data.data?.items ?? data.data ?? []) as MyBet[];
|
|
} catch {
|
|
myBets.value = [];
|
|
} finally {
|
|
loadingMyBets.value = false;
|
|
}
|
|
}
|
|
|
|
const statusLabel = (status: string) => {
|
|
const s = status.toUpperCase();
|
|
if (s === 'WON' || s === 'WIN') return t('history.status_won');
|
|
if (s === 'LOST' || s === 'LOSE') return t('history.status_lost');
|
|
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return t('history.status_push');
|
|
return t('history.status_pending');
|
|
};
|
|
|
|
const statusClass = (status: string) => {
|
|
const s = status.toUpperCase();
|
|
if (s === 'WON' || s === 'WIN') return 'bet-status-won';
|
|
if (s === 'LOST' || s === 'LOSE') return 'bet-status-lost';
|
|
return 'bet-status-pending';
|
|
};
|
|
const marketsById = computed(() => {
|
|
const map = new Map<string, Market>();
|
|
for (const m of match.value?.markets ?? []) {
|
|
map.set(m.id, m);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
const visibleMarkets = computed(() => match.value?.markets ?? []);
|
|
|
|
const kickoff = computed(() => {
|
|
if (!match.value) return '';
|
|
return formatLocalMatchDateTime(match.value.startTime, locale.value, { variant: 'full' });
|
|
});
|
|
|
|
const bettingOpen = computed(() => match.value?.bettingOpen !== false);
|
|
|
|
const matchPhase = computed(
|
|
(): MatchPhase =>
|
|
match.value?.matchPhase ?? (bettingOpen.value ? 'open' : 'closed_pending'),
|
|
);
|
|
|
|
const phaseLabel = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|
|
|
const liveScoreText = computed(() => {
|
|
const s = match.value?.score;
|
|
if (!s || s.ftHome == null || s.ftAway == null) return '';
|
|
return `${s.ftHome} - ${s.ftAway}`;
|
|
});
|
|
|
|
function statValue(value: number | null | undefined) {
|
|
return value == null ? '-' : String(value);
|
|
}
|
|
|
|
function statPair(home: number | null | undefined, away: number | null | undefined) {
|
|
return `${statValue(home)} - ${statValue(away)}`;
|
|
}
|
|
|
|
const resultStats = computed(() => {
|
|
const s = match.value?.score;
|
|
if (!s || matchPhase.value !== 'settled') return [];
|
|
return [
|
|
{ key: 'ht', label: t('bet.result_ht'), value: statPair(s.htHome, s.htAway) },
|
|
{ key: 'ft', label: t('bet.result_ft'), value: statPair(s.ftHome, s.ftAway) },
|
|
{ key: 'corners', label: t('bet.result_corners'), value: statPair(s.homeCorners, s.awayCorners) },
|
|
{
|
|
key: 'yellow',
|
|
label: t('bet.result_yellow_cards'),
|
|
value: statPair(s.homeYellowCards, s.awayYellowCards),
|
|
},
|
|
{ key: 'red', label: t('bet.result_red_cards'), value: statPair(s.homeRedCards, s.awayRedCards) },
|
|
];
|
|
});
|
|
|
|
const showResultStats = computed(() => resultStats.value.length > 0);
|
|
|
|
function marketLabel(market: Market | null | undefined) {
|
|
return market?.marketDisplayName?.trim() || market?.marketType || '';
|
|
}
|
|
|
|
function normalizeMarketStatus(status?: string) {
|
|
return (status ?? 'OPEN').toUpperCase();
|
|
}
|
|
|
|
function isMarketOpen(market: Market) {
|
|
return normalizeMarketStatus(market.status) === 'OPEN';
|
|
}
|
|
|
|
function marketStatusLabel(status?: string) {
|
|
const normalized = normalizeMarketStatus(status);
|
|
if (normalized === 'SUSPENDED') return t('bet.market_status_suspended');
|
|
if (normalized === 'CLOSED') return t('bet.market_status_closed');
|
|
return '';
|
|
}
|
|
|
|
function isMarketLocked(market: Market) {
|
|
if (!bettingOpen.value || !isMarketOpen(market)) return true;
|
|
const singleOk = market.allowSingle !== false;
|
|
const parlayOk = market.allowParlay !== false;
|
|
return !singleOk && !parlayOk;
|
|
}
|
|
|
|
function selectionLabel(sel: Selection) {
|
|
const parsedScore = parseScoreCode(sel.selectionCode, t);
|
|
if (parsedScore) return parsedScore.display;
|
|
return sel.selectionDisplayName?.trim() || sel.selectionName;
|
|
}
|
|
|
|
async function loadMatch() {
|
|
loading.value = true;
|
|
try {
|
|
const { data } = await api.get(`/player/matches/${route.params.id}`);
|
|
match.value = data.data;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
loadMyBets();
|
|
}
|
|
|
|
useOnLocaleChange(loadMatch);
|
|
|
|
const { pullDistance, spinning, progress } = usePullToRefresh({
|
|
onRefresh: loadMatch,
|
|
});
|
|
|
|
const pullIndicatorStyle = () => ({
|
|
height: `${pullDistance.value}px`,
|
|
opacity: Math.min(pullDistance.value / 48, 1),
|
|
});
|
|
|
|
function isSelected(id: string) {
|
|
return slip.singleItem?.selectionId === id || slip.parlayItems.some((i) => i.selectionId === id);
|
|
}
|
|
|
|
function toggleSelection(sel: Selection, market: Market) {
|
|
if (!match.value || isMarketLocked(market)) return;
|
|
if (!auth.token) {
|
|
goLogin();
|
|
return;
|
|
}
|
|
slip.addItem({
|
|
selectionId: sel.id,
|
|
oddsVersion: String(sel.oddsVersion),
|
|
matchId: match.value.id,
|
|
matchName: `${match.value.homeTeamName} vs ${match.value.awayTeamName}`,
|
|
marketId: market.id,
|
|
marketName: marketLabel(market),
|
|
selectionName: selectionLabel(sel),
|
|
odds: parseFloat(sel.odds),
|
|
marketType: market.marketType,
|
|
lineValue:
|
|
market.lineValue != null && market.lineValue !== ''
|
|
? parseFloat(String(market.lineValue))
|
|
: null,
|
|
allowSingle: market.allowSingle,
|
|
allowParlay: market.allowParlay,
|
|
});
|
|
slip.openDrawer();
|
|
}
|
|
|
|
function onPickSelection(selId: string, marketId: string) {
|
|
const market = marketsById.value.get(marketId);
|
|
const sel = market?.selections.find((s) => s.id === selId);
|
|
if (!market || !sel) return;
|
|
toggleSelection(sel, market);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="detail-page">
|
|
<div
|
|
class="pull-indicator"
|
|
:style="pullIndicatorStyle()"
|
|
>
|
|
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
|
</div>
|
|
|
|
<header class="toolbar">
|
|
<button type="button" class="icon-btn" :aria-label="t('bet.back')" @click="router.back()">←</button>
|
|
<span class="toolbar-title">{{ match?.leagueName ?? '' }}</span>
|
|
<div class="toolbar-actions">
|
|
<MatchBetGuide />
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
:aria-label="t('bet.refresh')"
|
|
:disabled="loading"
|
|
@click="loadMatch"
|
|
>
|
|
↻
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div v-if="loading" class="state">
|
|
<GoldSpinner :size="36" />
|
|
</div>
|
|
<template v-else-if="match">
|
|
<section class="match-hero" :class="{ 'match-hero--phase': matchPhase !== 'open' }">
|
|
<span v-if="matchPhase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
|
|
<span v-else-if="matchPhase === 'closed_pending'" class="status-tag status-tag--pending">{{ phaseLabel }}</span>
|
|
<div class="hero-teams">
|
|
<!-- home -->
|
|
<div class="hero-team">
|
|
<TeamEmblem
|
|
size="lg"
|
|
:team-code="match.homeTeamCode"
|
|
:team-name="match.homeTeamName"
|
|
:logo-url="match.homeTeamLogoUrl"
|
|
/>
|
|
<span class="hero-name">{{ match.homeTeamName }}</span>
|
|
</div>
|
|
|
|
<!-- VS arena with lightning -->
|
|
<div class="vs-arena">
|
|
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
|
|
<defs>
|
|
<linearGradient id="hzDetailGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stop-color="#5eb8ff" stop-opacity="0.2" />
|
|
<stop offset="35%" stop-color="#b8ecff" stop-opacity="1" />
|
|
<stop offset="50%" stop-color="#ffffff" stop-opacity="1" />
|
|
<stop offset="65%" stop-color="#ffd080" stop-opacity="1" />
|
|
<stop offset="100%" stop-color="#ff9040" stop-opacity="0.2" />
|
|
</linearGradient>
|
|
</defs>
|
|
<path
|
|
class="hz-path hz-path-main"
|
|
stroke="url(#hzDetailGrad)"
|
|
d="M1 14 H16 L20 5 L24 23 L28 9 L32 14 H40 L44 6 L48 22 L52 12 L56 14 H71"
|
|
/>
|
|
<path
|
|
class="hz-path hz-path-sub"
|
|
stroke="url(#hzDetailGrad)"
|
|
d="M3 19 H14 L18 15 L22 19 H50 L54 16 L58 19 H69"
|
|
/>
|
|
</svg>
|
|
<span class="hz-beam" aria-hidden="true" />
|
|
<img :src="vsImg" alt="" class="vs-img" />
|
|
</div>
|
|
|
|
<!-- away -->
|
|
<div class="hero-team">
|
|
<TeamEmblem
|
|
size="lg"
|
|
:team-code="match.awayTeamCode"
|
|
:team-name="match.awayTeamName"
|
|
:logo-url="match.awayTeamLogoUrl"
|
|
/>
|
|
<span class="hero-name">{{ match.awayTeamName }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="kickoff">{{ t('bet.kickoff_time') }}{{ kickoff }}</p>
|
|
<p v-if="liveScoreText" class="live-score">{{ liveScoreText }}</p>
|
|
</section>
|
|
|
|
<section v-if="showResultStats" class="result-stats-section">
|
|
<div class="result-stats-head">
|
|
<h3>{{ t('bet.result_stats_title') }}</h3>
|
|
<span>{{ match.homeTeamName }} / {{ match.awayTeamName }}</span>
|
|
</div>
|
|
<div class="result-stats-grid">
|
|
<div v-for="item in resultStats" :key="item.key" class="result-stat-item">
|
|
<span class="result-stat-label">{{ item.label }}</span>
|
|
<span class="result-stat-value">{{ item.value }}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 我的投注 -->
|
|
<section v-if="myBets.length" class="my-bets-section">
|
|
<h3 class="my-bets-title">{{ t('history.my_bets') || '我的投注' }}</h3>
|
|
<div class="my-bets-list">
|
|
<div v-for="bet in myBets" :key="bet.betNo" class="my-bet-card" @click="router.push(`/bets/${bet.betNo}`)">
|
|
<div class="bet-header">
|
|
<span class="bet-type">{{ bet.betType === 'PARLAY' ? t('history.parlay_league') : bet.pickLabel }}</span>
|
|
<span class="bet-status" :class="statusClass(bet.status)">{{ statusLabel(bet.status) }}</span>
|
|
</div>
|
|
<div class="bet-footer">
|
|
<span class="bet-stake">{{ t('history.stake') }} {{ formatMoney(bet.stake, locale) }}</span>
|
|
<span class="bet-return" :class="statusClass(bet.status)">
|
|
{{ statusClass(bet.status) === 'bet-status-won' ? '+' : '' }}{{ formatMoney(bet.actualReturn || bet.potentialReturn, locale) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="markets-section">
|
|
<div class="market-list">
|
|
<div
|
|
v-for="market in visibleMarkets"
|
|
:key="market.id"
|
|
class="market-group"
|
|
:class="{ open: true }"
|
|
>
|
|
<MarketTypeTile
|
|
:label="marketLabel(market)"
|
|
:promo-label="market.promoLabel?.trim() || ''"
|
|
:status-label="bettingOpen && !isMarketOpen(market) ? marketStatusLabel(market.status) : ''"
|
|
:has-market="true"
|
|
:expanded="true"
|
|
/>
|
|
<div class="market-panel-wrap" :class="{ locked: isMarketLocked(market) }">
|
|
<span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span>
|
|
<span v-else-if="!bettingOpen" class="market-status-tag market-status-tag--pending">{{ phaseLabel }}</span>
|
|
<span
|
|
v-else-if="!isMarketOpen(market)"
|
|
class="market-status-tag"
|
|
:class="normalizeMarketStatus(market.status) === 'CLOSED' ? 'market-status-tag--closed' : 'market-status-tag--suspended'"
|
|
>
|
|
{{ marketStatusLabel(market.status) }}
|
|
</span>
|
|
<CorrectScorePanel
|
|
v-if="isCorrectScoreMarket(market.marketType)"
|
|
:market-type="market.marketType"
|
|
:selections="market.selections"
|
|
:locked="isMarketLocked(market)"
|
|
:is-selected="isSelected"
|
|
@pick="onPickSelection($event, market.id)"
|
|
/>
|
|
<MarketSelectionsPanel
|
|
v-else
|
|
compact
|
|
:locked="isMarketLocked(market)"
|
|
:line-value="market.lineValue"
|
|
:selections="market.selections"
|
|
:is-selected="isSelected"
|
|
@pick="onPickSelection($event, market.id)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.detail-page {
|
|
margin: 0 -16px;
|
|
padding-bottom: 16px;
|
|
}
|
|
|
|
.pull-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
transition: height 0.15s ease;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 4px 12px 8px;
|
|
gap: 8px;
|
|
position: sticky;
|
|
top: -12px;
|
|
z-index: 50;
|
|
margin-top: -12px;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
.toolbar-title {
|
|
flex: 1;
|
|
text-align: center;
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
color: var(--primary-light);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
padding: 0 4px;
|
|
}
|
|
|
|
.toolbar-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.icon-btn {
|
|
flex-shrink: 0;
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 50%;
|
|
border: 1px solid var(--border-gold-soft);
|
|
background: rgba(0, 0, 0, 0.2);
|
|
color: var(--primary-light);
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
padding: 0;
|
|
}
|
|
|
|
.icon-btn:disabled {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
/* ── match hero ── */
|
|
.match-hero {
|
|
position: relative;
|
|
isolation: isolate;
|
|
margin: 0 0 10px;
|
|
padding: 14px 12px 16px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.match-hero--phase {
|
|
opacity: 0.98;
|
|
}
|
|
|
|
/* ── hero status tag (top-right corner) ── */
|
|
.status-tag {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
z-index: 5;
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
padding: 4px 14px;
|
|
border-radius: 0 6px 0 10px;
|
|
line-height: 1.3;
|
|
white-space: nowrap;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
/* ── market panel smaller status tag ── */
|
|
.market-status-tag {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
z-index: 5;
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
padding: 3px 10px;
|
|
border-radius: 0 4px 0 8px;
|
|
line-height: 1.3;
|
|
white-space: nowrap;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.market-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);
|
|
}
|
|
|
|
.market-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);
|
|
}
|
|
|
|
.market-status-tag--suspended {
|
|
background: linear-gradient(180deg, rgba(240, 180, 41, 0.28) 0%, rgba(120, 80, 10, 0.92) 100%);
|
|
color: #ffe08a;
|
|
border-bottom: 1px solid rgba(240, 180, 41, 0.45);
|
|
border-left: 1px solid rgba(240, 180, 41, 0.45);
|
|
}
|
|
|
|
.market-status-tag--closed {
|
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(40, 40, 55, 0.95) 100%);
|
|
color: #8a9ab8;
|
|
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
|
|
border-left: 1px solid rgba(120, 140, 180, 0.3);
|
|
}
|
|
|
|
.market-panel-wrap {
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.market-panel-wrap.locked {
|
|
opacity: 0.82;
|
|
}
|
|
|
|
.kickoff {
|
|
position: relative;
|
|
z-index: 1;
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
text-align: left;
|
|
margin-top: 10px;
|
|
padding-left: 2px;
|
|
}
|
|
|
|
.live-score {
|
|
position: relative;
|
|
z-index: 2;
|
|
margin: 8px 0 0;
|
|
font-size: 28px;
|
|
font-weight: 900;
|
|
color: #fff;
|
|
text-align: center;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
|
|
.result-stats-section {
|
|
margin: 0 10px 12px;
|
|
padding: 12px;
|
|
border: 1px solid rgba(212, 175, 55, 0.22);
|
|
border-radius: 8px;
|
|
background: linear-gradient(180deg, #1b1b1b 0%, #121212 100%);
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
|
|
}
|
|
|
|
.result-stats-head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.result-stats-head h3 {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
line-height: 1.2;
|
|
font-weight: 900;
|
|
color: var(--primary-light);
|
|
}
|
|
|
|
.result-stats-head span {
|
|
min-width: 0;
|
|
max-width: 58%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.result-stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.result-stat-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
min-height: 38px;
|
|
gap: 8px;
|
|
padding: 8px 10px;
|
|
border-radius: 6px;
|
|
background: rgba(255, 255, 255, 0.035);
|
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
.result-stat-label {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.result-stat-value {
|
|
flex-shrink: 0;
|
|
font-size: 14px;
|
|
line-height: 1;
|
|
font-weight: 900;
|
|
color: #fff;
|
|
}
|
|
|
|
.hero-teams {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
.hero-team {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.hero-name {
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
color: var(--primary-light);
|
|
text-align: center;
|
|
line-height: 1.25;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* VS arena — same as HomeView */
|
|
.vs-arena {
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
width: 140px;
|
|
height: 126px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.hz-lightning {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: 1;
|
|
pointer-events: none;
|
|
overflow: visible;
|
|
}
|
|
|
|
.hz-path {
|
|
fill: none;
|
|
stroke-width: 2.2;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
filter: drop-shadow(0 0 4px rgba(120, 210, 255, 0.95)) drop-shadow(0 0 8px rgba(255, 180, 80, 0.55));
|
|
opacity: 0;
|
|
}
|
|
|
|
.hz-path-main {
|
|
animation: hz-strike-main 2.6s ease-in-out infinite;
|
|
}
|
|
|
|
.hz-path-sub {
|
|
stroke-width: 1.6;
|
|
animation: hz-strike-sub 2.6s ease-in-out infinite;
|
|
animation-delay: 0.12s;
|
|
}
|
|
|
|
.hz-beam {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: 50%;
|
|
height: 2px;
|
|
transform: translateY(-50%);
|
|
pointer-events: none;
|
|
z-index: 1;
|
|
background: linear-gradient(
|
|
90deg,
|
|
rgba(94, 184, 255, 0) 0%,
|
|
rgba(184, 236, 255, 0.95) 28%,
|
|
#fff 50%,
|
|
rgba(255, 208, 128, 0.95) 72%,
|
|
rgba(255, 144, 64, 0) 100%
|
|
);
|
|
opacity: 0;
|
|
filter: blur(0.4px);
|
|
animation: hz-beam-flash 2.6s ease-in-out infinite;
|
|
}
|
|
|
|
.vs-img {
|
|
position: relative;
|
|
z-index: 0;
|
|
width: 126px;
|
|
height: auto;
|
|
object-fit: contain;
|
|
}
|
|
|
|
@keyframes hz-strike-main {
|
|
0%, 72%, 100% { opacity: 0; }
|
|
74% { opacity: 1; }
|
|
75% { opacity: 0.25; }
|
|
76% { opacity: 0.95; }
|
|
78% { opacity: 0; }
|
|
}
|
|
|
|
@keyframes hz-strike-sub {
|
|
0%, 74%, 100% { opacity: 0; }
|
|
76% { opacity: 0.85; }
|
|
77% { opacity: 0.2; }
|
|
78% { opacity: 0.7; }
|
|
80% { opacity: 0; }
|
|
}
|
|
|
|
@keyframes hz-beam-flash {
|
|
0%, 71%, 100% { opacity: 0; transform: translateY(-50%) scaleX(0.6); }
|
|
73% { opacity: 0.85; transform: translateY(-50%) scaleX(1); }
|
|
75% { opacity: 0.15; transform: translateY(-50%) scaleX(0.95); }
|
|
76% { opacity: 0.75; transform: translateY(-50%) scaleX(1); }
|
|
78% { opacity: 0; transform: translateY(-50%) scaleX(1.05); }
|
|
}
|
|
|
|
@keyframes vs-glow {
|
|
0%, 100% {
|
|
opacity: 0.82;
|
|
filter: drop-shadow(0 0 2px rgba(212, 175, 55, 0.3));
|
|
}
|
|
50% {
|
|
opacity: 1;
|
|
filter: drop-shadow(0 0 3px rgba(255, 230, 140, 0.7)) drop-shadow(0 0 6px rgba(212, 175, 55, 0.35));
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.hz-path, .hz-beam { animation: none; opacity: 0; }
|
|
}
|
|
|
|
.markets-section {
|
|
padding: 0 10px 8px;
|
|
}
|
|
|
|
.market-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
padding: 4px 0;
|
|
}
|
|
|
|
.market-group {
|
|
border-radius: 8px;
|
|
border: 1px solid #252525;
|
|
background: linear-gradient(180deg, #1a1a1a 0%, #151515 100%);
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.35);
|
|
transition: border-color 0.2s, box-shadow 0.2s;
|
|
}
|
|
|
|
.market-group.open {
|
|
border-color: rgba(212, 175, 55, 0.25);
|
|
box-shadow: 0 4px 16px rgba(212, 175, 55, 0.08);
|
|
}
|
|
|
|
.market-group :deep(.row) {
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.market-group.open :deep(.row) {
|
|
border-radius: 0;
|
|
}
|
|
|
|
.market-foot-btn {
|
|
display: block;
|
|
width: calc(100% - 16px);
|
|
margin: 0 8px 10px;
|
|
padding: 9px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border-gold-soft);
|
|
background: rgba(212, 175, 55, 0.1);
|
|
color: var(--primary-light);
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.state {
|
|
text-align: center;
|
|
padding: 48px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.cs-toast {
|
|
text-align: center;
|
|
font-size: 12px;
|
|
color: var(--primary-light);
|
|
padding: 2px 0 6px;
|
|
}
|
|
|
|
/* ── 我的投注 ── */
|
|
.my-bets-section {
|
|
padding: 0 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.my-bets-title {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: #ccc;
|
|
margin: 0 0 8px;
|
|
}
|
|
|
|
.my-bets-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.my-bet-card {
|
|
background: #1c1c1c;
|
|
border: 1px solid #2e2e2e;
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.my-bet-card:active {
|
|
background: #252525;
|
|
}
|
|
|
|
.bet-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.bet-type {
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
flex: 1;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.bet-status {
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.bet-status-won {
|
|
background: rgba(61, 184, 101, 0.15);
|
|
color: #3db865;
|
|
}
|
|
|
|
.bet-status-lost {
|
|
background: rgba(224, 80, 80, 0.15);
|
|
color: #e05050;
|
|
}
|
|
|
|
.bet-status-pending {
|
|
background: rgba(232, 200, 74, 0.15);
|
|
color: #e8c84a;
|
|
}
|
|
|
|
.bet-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.bet-stake {
|
|
font-size: 11px;
|
|
color: #888;
|
|
}
|
|
|
|
.bet-return {
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
</style>
|