重构
This commit is contained in:
@@ -17,6 +17,12 @@ api.interceptors.request.use((config) => {
|
||||
|
||||
const locale = localStorage.getItem('locale') || 'zh-CN';
|
||||
config.headers['X-Locale'] = locale;
|
||||
try {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (timeZone) config.headers['X-Time-Zone'] = timeZone;
|
||||
} catch {
|
||||
// Some WebViews may not expose a time zone; the API falls back to UTC.
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
@@ -123,14 +123,23 @@ onUnmounted(stopAutoPlay);
|
||||
class="slide"
|
||||
@click="onBannerClick(banner)"
|
||||
>
|
||||
<img
|
||||
v-if="imageUrl(banner)"
|
||||
:src="imageUrl(banner)"
|
||||
:alt="title(banner)"
|
||||
class="slide-img"
|
||||
:loading="i === 0 ? 'eager' : 'lazy'"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<template v-if="imageUrl(banner)">
|
||||
<img
|
||||
:src="imageUrl(banner)"
|
||||
alt=""
|
||||
class="slide-backdrop"
|
||||
aria-hidden="true"
|
||||
:loading="i === 0 ? 'eager' : 'lazy'"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<img
|
||||
:src="imageUrl(banner)"
|
||||
:alt="title(banner)"
|
||||
class="slide-img"
|
||||
:loading="i === 0 ? 'eager' : 'lazy'"
|
||||
@error="onImgError"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="slide-fallback">{{ title(banner) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,40 +166,81 @@ onUnmounted(stopAutoPlay);
|
||||
<style scoped>
|
||||
.carousel {
|
||||
position: relative;
|
||||
margin: 0 -16px 14px;
|
||||
margin: -12px -16px 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #05080d;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: clamp(148px, 41.86vw, 180px);
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.slide {
|
||||
position: relative;
|
||||
flex: 0 0 100%;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(7, 18, 31, 0.94), rgba(6, 8, 12, 0.98)),
|
||||
#070d15;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slide-img {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
.slide::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.03) 48%, rgba(0, 0, 0, 0.18));
|
||||
box-shadow: inset 0 -1px 0 rgba(212, 175, 55, 0.16);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide-backdrop {
|
||||
position: absolute;
|
||||
inset: -18px;
|
||||
width: calc(100% + 36px);
|
||||
height: calc(100% + 36px);
|
||||
object-fit: cover;
|
||||
filter: blur(16px);
|
||||
opacity: 0.42;
|
||||
transform: scale(1.04);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide-img {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide-fallback {
|
||||
height: 180px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -210,6 +260,7 @@ onUnmounted(stopAutoPlay);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: var(--primary-light);
|
||||
border: 1px solid var(--border);
|
||||
padding-bottom: 5px;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import {
|
||||
useBetSlipStore,
|
||||
type ParlaySlipError,
|
||||
type SlipItem,
|
||||
type SlipMode,
|
||||
} from '../stores/betSlip';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { formatMoney, parseAmount } from '../utils/localeDisplay';
|
||||
import BetSuccessOverlay from './BetSuccessOverlay.vue';
|
||||
import api from '../api';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
const show = computed({
|
||||
@@ -18,68 +24,246 @@ const show = computed({
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
const activeTab = ref<SlipMode>('single');
|
||||
const loading = ref(false);
|
||||
const balanceLoading = ref(false);
|
||||
const balance = ref<number | null>(null);
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
const showSuccess = ref(false);
|
||||
const MIN_STAKE = 5;
|
||||
const MAX_STAKE_INTEGER_LENGTH = 9;
|
||||
const stakeInput = ref('');
|
||||
const keypadKeys = ['1', '2', '3', '4', '5', 'backspace', '6', '7', '8', '9', '0', '00'];
|
||||
|
||||
const activeItems = computed<SlipItem[]>(() => {
|
||||
if (activeTab.value === 'parlay') return slip.parlayItems;
|
||||
return slip.singleItem ? [slip.singleItem] : [];
|
||||
});
|
||||
|
||||
const activeCount = computed(() => activeItems.value.length);
|
||||
const activeTotalOdds = computed(() =>
|
||||
activeItems.value.reduce((acc, item) => acc * item.odds, 1),
|
||||
);
|
||||
const activeEstimatedReturn = computed(() => {
|
||||
if (!activeItems.value.length || !Number.isFinite(slip.stake) || slip.stake <= 0) return 0;
|
||||
if (activeTab.value === 'parlay') return slip.stake * activeTotalOdds.value;
|
||||
return slip.stake * activeItems.value[0].odds;
|
||||
});
|
||||
|
||||
const canSubmitActive = computed(() => {
|
||||
if (activeTab.value === 'parlay') {
|
||||
return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS;
|
||||
}
|
||||
return Boolean(slip.singleItem);
|
||||
});
|
||||
|
||||
const balanceText = computed(() => {
|
||||
if (balanceLoading.value) return t('bet.loading');
|
||||
if (balance.value == null) return '--';
|
||||
return formatMoney(balance.value, locale.value);
|
||||
});
|
||||
|
||||
const stakeText = computed(() => formatMoney(slip.stake, locale.value));
|
||||
const estimatedReturnText = computed(() => formatMoney(activeEstimatedReturn.value, locale.value));
|
||||
const totalOddsText = computed(() => activeTotalOdds.value.toFixed(4).replace(/0+$/, '').replace(/\.$/, ''));
|
||||
|
||||
const parlayWarning = computed(() => {
|
||||
if (activeTab.value !== 'parlay') return '';
|
||||
if (slip.lastParlayError) return parlayErrorMessage(slip.lastParlayError);
|
||||
if (slip.parlayItems.length > 0 && slip.parlayItems.length < PARLAY_MIN_LEGS) {
|
||||
return t('bet.parlay_need_more');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const singleInParlay = computed(() => {
|
||||
const item = slip.singleItem;
|
||||
return Boolean(item && slip.parlayItems.some((leg) => leg.selectionId === item.selectionId));
|
||||
});
|
||||
|
||||
const showFooterParlayAction = computed(() => activeTab.value === 'single' && Boolean(slip.singleItem));
|
||||
|
||||
function genId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function onSuccessDone() {
|
||||
showSuccess.value = false;
|
||||
function selectTab(tab: SlipMode) {
|
||||
activeTab.value = tab;
|
||||
slip.setMode(tab);
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
show.value = false;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
}
|
||||
|
||||
function onSuccessDone() {
|
||||
showSuccess.value = false;
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
async function loadBalance() {
|
||||
if (!auth.token) {
|
||||
balance.value = null;
|
||||
return;
|
||||
}
|
||||
balanceLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/profile');
|
||||
balance.value = parseAmount(data.data?.wallet?.availableBalance);
|
||||
} catch {
|
||||
balance.value = null;
|
||||
} finally {
|
||||
balanceLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parlayErrorMessage(reason: ParlaySlipError) {
|
||||
if (reason === 'MAX_LEGS') return t('bet.parlay_max_legs');
|
||||
if (reason === 'QUARTER_LINE') return t('bet.parlay_block_quarter');
|
||||
if (reason === 'OUTRIGHT') return t('bet.parlay_block_outright');
|
||||
if (reason === 'NOT_ALLOWED') return t('bet.parlay_block_not_allowed');
|
||||
if (reason === 'SAME_MATCH') return t('bet.slip_parlay_same_match');
|
||||
return t('bet.parlay_block_not_allowed');
|
||||
}
|
||||
|
||||
function addCurrentToParlay() {
|
||||
if (singleInParlay.value) {
|
||||
activeTab.value = 'parlay';
|
||||
slip.setMode('parlay');
|
||||
error.value = '';
|
||||
return;
|
||||
}
|
||||
const err = slip.addSingleToParlay();
|
||||
activeTab.value = 'parlay';
|
||||
if (err) {
|
||||
error.value = parlayErrorMessage(err);
|
||||
return;
|
||||
}
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
slip.removeItem(id);
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function stakeAmountToInput(amount: number) {
|
||||
if (!Number.isFinite(amount) || amount <= 0) return '';
|
||||
const rounded = Math.round(amount * 100) / 100;
|
||||
return Number.isInteger(rounded)
|
||||
? String(rounded)
|
||||
: rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function sanitizeStakeInput(raw: string) {
|
||||
const onlyAmountChars = raw.replace(/[^\d.]/g, '');
|
||||
const [integerRaw, ...fractionParts] = onlyAmountChars.split('.');
|
||||
const integerPart = integerRaw.replace(/^0+(?=\d)/, '').slice(0, MAX_STAKE_INTEGER_LENGTH);
|
||||
if (!fractionParts.length) return integerPart;
|
||||
const fractionPart = fractionParts.join('').slice(0, 2);
|
||||
return `${integerPart || '0'}.${fractionPart}`;
|
||||
}
|
||||
|
||||
function commitStakeInput(raw: string) {
|
||||
const clean = sanitizeStakeInput(raw);
|
||||
stakeInput.value = clean;
|
||||
const amount = Number.parseFloat(clean);
|
||||
slip.stake = Number.isFinite(amount) ? amount : 0;
|
||||
}
|
||||
|
||||
function syncStakeInputFromSlip() {
|
||||
stakeInput.value = stakeAmountToInput(Number(slip.stake) || 0);
|
||||
}
|
||||
|
||||
function onStakeInput(event: Event) {
|
||||
commitStakeInput((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function pressStakeKey(key: string) {
|
||||
if (key === 'backspace') {
|
||||
commitStakeInput(stakeInput.value.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
const base = stakeInput.value === '0' ? '' : stakeInput.value;
|
||||
commitStakeInput(`${base}${key}`);
|
||||
}
|
||||
|
||||
function clearStakeInput() {
|
||||
commitStakeInput('');
|
||||
}
|
||||
|
||||
function setStake(amount: number, enforceMinimum = true) {
|
||||
if (!Number.isFinite(amount)) return;
|
||||
const normalized = Math.round((enforceMinimum ? Math.max(MIN_STAKE, amount) : amount) * 100) / 100;
|
||||
slip.stake = normalized;
|
||||
stakeInput.value = stakeAmountToInput(normalized);
|
||||
}
|
||||
|
||||
function addStake(amount: number) {
|
||||
setStake((Number(slip.stake) || 0) + amount);
|
||||
}
|
||||
|
||||
function setMaxStake() {
|
||||
if (balance.value != null && balance.value > 0) setStake(balance.value, false);
|
||||
}
|
||||
|
||||
async function placeBet() {
|
||||
if (!slip.items.length) return;
|
||||
if (!activeItems.value.length) return;
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt();
|
||||
return;
|
||||
}
|
||||
if (slip.stake < MIN_STAKE) {
|
||||
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
|
||||
return;
|
||||
}
|
||||
if (balance.value != null && slip.stake > balance.value) {
|
||||
error.value = t('bet.outright_insufficient');
|
||||
return;
|
||||
}
|
||||
if (activeTab.value === 'parlay' && !canSubmitActive.value) {
|
||||
error.value = slip.parlayItems.length > PARLAY_MAX_LEGS
|
||||
? t('bet.parlay_max_legs')
|
||||
: t('bet.parlay_need_more');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
|
||||
try {
|
||||
if (slip.canPlaceParlay) {
|
||||
if (slip.items.length > PARLAY_MAX_LEGS) {
|
||||
error.value = t('bet.parlay_max_legs');
|
||||
return;
|
||||
}
|
||||
if (activeTab.value === 'parlay') {
|
||||
await api.post('/player/bets/parlay', {
|
||||
legs: slip.items.map((i) => ({
|
||||
selectionId: i.selectionId,
|
||||
oddsVersion: i.oddsVersion,
|
||||
legs: slip.parlayItems.map((item) => ({
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
})),
|
||||
stake: slip.stake,
|
||||
requestId: genId(),
|
||||
});
|
||||
} else if (slip.canPlaceBatchSingles) {
|
||||
for (const item of slip.items) {
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
stake: slip.stake,
|
||||
requestId: genId(),
|
||||
});
|
||||
}
|
||||
slip.clearParlay();
|
||||
} else {
|
||||
error.value = t('bet.parlay_need_more');
|
||||
return;
|
||||
const item = slip.singleItem;
|
||||
if (!item) return;
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
stake: slip.stake,
|
||||
requestId: genId(),
|
||||
});
|
||||
slip.clearSingle();
|
||||
}
|
||||
success.value = t('bet.place_success');
|
||||
slip.clear();
|
||||
showSuccess.value = true;
|
||||
await loadBalance();
|
||||
setTimeout(() => {
|
||||
if (showSuccess.value) {
|
||||
showSuccess.value = false;
|
||||
show.value = false;
|
||||
success.value = '';
|
||||
}
|
||||
}, 2500);
|
||||
if (showSuccess.value) onSuccessDone();
|
||||
}, 2200);
|
||||
} catch (e: unknown) {
|
||||
error.value =
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||
@@ -88,63 +272,176 @@ async function placeBet() {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
activeTab.value = slip.mode;
|
||||
if (activeTab.value === 'single' && !slip.singleItem && slip.parlayItems.length) {
|
||||
activeTab.value = 'parlay';
|
||||
slip.setMode('parlay');
|
||||
}
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
syncStakeInputFromSlip();
|
||||
loadBalance();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => slip.mode,
|
||||
(mode) => {
|
||||
if (show.value) activeTab.value = mode;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="overlay" @click.self="show = false">
|
||||
<div v-if="show" class="overlay" @click.self="closeDrawer">
|
||||
<div class="drawer">
|
||||
<div class="drawer-head">
|
||||
<div>
|
||||
<p class="drawer-kicker">{{ t('bet.bet_slip') }}</p>
|
||||
<h3>{{ t('bet.slip_review_title') }}</h3>
|
||||
</div>
|
||||
<button type="button" class="close-btn" :aria-label="t('bet.cancel')" @click="closeDrawer">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="balance-bar">
|
||||
<span>{{ t('bet.slip_balance') }}</span>
|
||||
<strong>{{ balanceText }}</strong>
|
||||
<button type="button" class="balance-refresh" :disabled="balanceLoading" @click="loadBalance">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="slip-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="slip-tab"
|
||||
:class="{ active: activeTab === 'single' }"
|
||||
@click="selectTab('single')"
|
||||
>
|
||||
{{ t('bet.slip_tab_single') }}
|
||||
<span v-if="slip.singleCount" class="tab-count">{{ slip.singleCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="slip-tab"
|
||||
:class="{ active: activeTab === 'parlay' }"
|
||||
@click="selectTab('parlay')"
|
||||
>
|
||||
{{ t('bet.slip_tab_parlay') }}
|
||||
<span v-if="slip.parlayCount" class="tab-count">{{ slip.parlayCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="drawer-body">
|
||||
<div class="drawer-header">
|
||||
<h3>{{ t('bet.bet_slip') }} <span class="count">({{ slip.count }})</span></h3>
|
||||
<button type="button" class="close-btn" :aria-label="t('bet.cancel')" @click="show = false">
|
||||
✕
|
||||
</button>
|
||||
<div v-if="!activeItems.length" class="empty">
|
||||
{{ activeTab === 'parlay' ? t('bet.slip_parlay_empty_hint') : t('bet.slip_empty_hint') }}
|
||||
</div>
|
||||
|
||||
<div v-if="!slip.items.length" class="empty">{{ t('bet.slip_empty_hint') }}</div>
|
||||
|
||||
<div v-for="item in slip.items" :key="item.selectionId" class="slip-item">
|
||||
<div class="item-name">{{ item.matchName }}</div>
|
||||
<div class="item-sel">
|
||||
{{ item.selectionName }} @ <span class="odds">{{ item.odds }}</span>
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 'single' && slip.singleItem" class="slip-item slip-item--single">
|
||||
<div class="item-main">
|
||||
<div class="item-title">{{ slip.singleItem.matchName }}</div>
|
||||
<div v-if="slip.singleItem.marketName" class="item-market">{{ slip.singleItem.marketName }}</div>
|
||||
<div class="item-pick">{{ slip.singleItem.selectionName }}</div>
|
||||
</div>
|
||||
<div class="item-odds">{{ slip.singleItem.odds.toFixed(2) }}</div>
|
||||
</div>
|
||||
<button type="button" class="remove" @click="slip.removeItem(item.selectionId)">
|
||||
{{ t('bet.slip_remove') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="slip.canPlaceParlay" class="mode-hint mode-hint--parlay">
|
||||
{{ t('bet.parlay') }} · {{ t('bet.slip_parlay_odds', { odds: slip.totalOdds.toFixed(2) }) }}
|
||||
</p>
|
||||
<p v-else-if="slip.canPlaceBatchSingles && slip.count > 1" class="mode-hint">
|
||||
{{ t('bet.slip_singles_hint', { n: slip.count }) }}
|
||||
</p>
|
||||
<template v-if="activeTab === 'parlay'">
|
||||
<p v-if="parlayWarning" class="warning">{{ parlayWarning }}</p>
|
||||
<div
|
||||
v-for="item in slip.parlayItems"
|
||||
:key="item.selectionId"
|
||||
class="slip-item"
|
||||
>
|
||||
<div class="item-main">
|
||||
<div class="item-title">{{ item.matchName }}</div>
|
||||
<div v-if="item.marketName" class="item-market">{{ item.marketName }}</div>
|
||||
<div class="item-pick">{{ item.selectionName }}</div>
|
||||
</div>
|
||||
<div class="item-side">
|
||||
<strong>{{ item.odds.toFixed(2) }}</strong>
|
||||
<button type="button" class="remove" @click="removeItem(item.selectionId)">
|
||||
{{ t('bet.slip_remove') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="slip.parlayItems.length" class="parlay-meta">
|
||||
{{ t('bet.slip_parlay_count', { n: slip.parlayItems.length }) }}
|
||||
· {{ t('bet.slip_parlay_odds', { odds: totalOddsText }) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div v-if="slip.items.length" class="stake-area">
|
||||
<label>{{
|
||||
slip.canPlaceBatchSingles && slip.count > 1
|
||||
? t('bet.slip_stake_per_bet')
|
||||
: t('bet.stake')
|
||||
}}</label>
|
||||
<input v-model.number="slip.stake" type="number" min="1" />
|
||||
<div class="return">
|
||||
{{ t('bet.slip_est_return') }}:
|
||||
<strong>{{ slip.potentialReturn.toFixed(2) }}</strong>
|
||||
<div class="stake-area">
|
||||
<div class="stake-head">
|
||||
<span>{{ activeTab === 'parlay' ? t('bet.slip_total_stake') : t('bet.stake') }}</span>
|
||||
<strong>{{ stakeText }}</strong>
|
||||
</div>
|
||||
<div class="stake-input-row">
|
||||
<span class="currency">{{ t('bet.slip_currency') }}</span>
|
||||
<input
|
||||
:value="stakeInput"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
@input="onStakeInput"
|
||||
/>
|
||||
<button type="button" class="input-clear" @click="clearStakeInput">✕</button>
|
||||
</div>
|
||||
<div class="number-pad">
|
||||
<button
|
||||
v-for="key in keypadKeys"
|
||||
:key="key"
|
||||
type="button"
|
||||
class="number-key"
|
||||
:class="{ 'number-key--backspace': key === 'backspace' }"
|
||||
@click="pressStakeKey(key)"
|
||||
>
|
||||
<span v-if="key === 'backspace'" aria-hidden="true">⌫</span>
|
||||
<template v-else>{{ key }}</template>
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick-stakes">
|
||||
<button type="button" @click="setStake(MIN_STAKE)">{{ t('bet.slip_min') }}</button>
|
||||
<button type="button" @click="addStake(50)">+50</button>
|
||||
<button type="button" @click="addStake(100)">+100</button>
|
||||
<button type="button" @click="setMaxStake">{{ t('bet.stake_max') }}</button>
|
||||
</div>
|
||||
<div class="return">
|
||||
<span>{{ t('bet.slip_est_return') }}</span>
|
||||
<strong>{{ estimatedReturnText }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="success" class="success">{{ success }}</p>
|
||||
</div>
|
||||
|
||||
<div class="drawer-foot">
|
||||
<div class="drawer-foot" :class="{ 'drawer-foot--split': showFooterParlayAction }">
|
||||
<button
|
||||
v-if="showFooterParlayAction"
|
||||
type="button"
|
||||
class="btn-add-parlay"
|
||||
@click="addCurrentToParlay"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
{{ t('bet.slip_add_parlay') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="loading || !slip.canSubmit"
|
||||
:disabled="loading || !canSubmitActive"
|
||||
@click="placeBet"
|
||||
>
|
||||
{{ loading ? t('bet.placing') : t('bet.place_bet') }}
|
||||
{{ loading ? t('bet.placing') : t('bet.place_bet_short') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,137 +461,395 @@ async function placeBet() {
|
||||
}
|
||||
|
||||
.drawer {
|
||||
background: linear-gradient(180deg, #222 0%, #141414 100%);
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
max-height: 88vh;
|
||||
background: #101010;
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 -20px 44px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.drawer-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
background: linear-gradient(180deg, #1d1d1d 0%, #111 100%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.drawer-kicker {
|
||||
margin: 0 0 3px;
|
||||
font-size: 11px;
|
||||
color: var(--primary-light);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.drawer-head h3 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.balance-bar {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.18);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.balance-bar strong {
|
||||
justify-self: end;
|
||||
color: var(--primary-light);
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.balance-refresh {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.slip-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
padding: 8px 12px 0;
|
||||
gap: 6px;
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.slip-tab {
|
||||
min-height: 38px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
border: 1px solid var(--border);
|
||||
background: #171717;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.slip-tab.active {
|
||||
border-color: var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.13);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 6px;
|
||||
border-radius: 9px;
|
||||
background: var(--primary);
|
||||
color: #111;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 0;
|
||||
padding: 12px 12px 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.drawer-foot {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(14, 14, 14, 0.98);
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.drawer-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 20px;
|
||||
padding: 4px;
|
||||
padding: 26px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.slip-item {
|
||||
padding: 10px 12px;
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 11px 12px;
|
||||
background: #151515;
|
||||
border: 1px solid #292929;
|
||||
border-radius: 7px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
.slip-item--single {
|
||||
border-color: var(--border-gold-soft);
|
||||
background: linear-gradient(180deg, rgba(212, 175, 55, 0.12), rgba(18, 18, 18, 0.94));
|
||||
}
|
||||
|
||||
.item-sel {
|
||||
.item-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-market {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.item-pick {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.odds {
|
||||
.item-odds,
|
||||
.item-side strong {
|
||||
color: var(--primary-light);
|
||||
font-weight: 800;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.item-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background: none;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mode-hint {
|
||||
.btn-add-parlay {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #4b370d 0%, #241905 100%);
|
||||
border: 1px solid var(--primary-light);
|
||||
color: #ffe892;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 232, 146, 0.18) inset,
|
||||
0 6px 18px rgba(212, 175, 55, 0.18);
|
||||
}
|
||||
|
||||
.btn-add-parlay span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-light);
|
||||
color: #191100;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.warning,
|
||||
.error {
|
||||
margin: 0 0 10px;
|
||||
padding: 10px 11px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 77, 79, 0.12);
|
||||
border: 1px solid rgba(255, 77, 79, 0.25);
|
||||
color: #ff8b8b;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mode-hint--parlay {
|
||||
.parlay-meta {
|
||||
margin: 8px 2px 10px;
|
||||
color: var(--primary-light);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stake-area {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.stake-area label {
|
||||
font-size: 12px;
|
||||
color: var(--primary-light);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.return {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.return strong {
|
||||
color: var(--primary-light);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
.stake-area {
|
||||
margin: 12px 0 12px;
|
||||
padding: 12px;
|
||||
background: #151515;
|
||||
border: 1px solid #292929;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stake-head,
|
||||
.return {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stake-head strong,
|
||||
.return strong {
|
||||
color: var(--primary-light);
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.stake-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0c0c0c;
|
||||
}
|
||||
|
||||
.currency {
|
||||
padding: 0 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
border-right: 1px solid #2d2d2d;
|
||||
}
|
||||
|
||||
.stake-input-row input {
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
padding: 0 10px;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.stake-input-row input::-webkit-outer-spin-button,
|
||||
.stake-input-row input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.input-clear {
|
||||
width: 38px;
|
||||
height: 42px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.number-pad,
|
||||
.quick-stakes {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.number-pad {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
.quick-stakes {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.number-key,
|
||||
.quick-stakes button {
|
||||
min-height: 52px;
|
||||
border-radius: 5px;
|
||||
background: #f4f1f6;
|
||||
color: #221f26;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.number-key--backspace span {
|
||||
display: inline-block;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.quick-stakes button {
|
||||
min-height: 50px;
|
||||
background: #7b787d;
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.return {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
.drawer-foot {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(14, 14, 14, 0.98);
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.drawer-foot--split {
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(180deg, var(--primary-light), var(--primary));
|
||||
color: #111;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ const stats = computed(() => {
|
||||
for (const bet of props.items) {
|
||||
total++;
|
||||
const s = bet.status.toUpperCase();
|
||||
if (s === 'WON' || s === 'WIN') { won++; totalReturn += parseAmount(bet.actualReturn); }
|
||||
if (s === 'WON' || s === 'WIN') won++;
|
||||
else if (s === 'LOST' || s === 'LOSE') lost++;
|
||||
else if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') push++;
|
||||
else pending++;
|
||||
@@ -56,11 +56,13 @@ const stats = computed(() => {
|
||||
<span class="stat-label">{{ t('history.stats_push') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-bar">
|
||||
<div class="bar-seg won" :style="{ flex: stats.won || 0.001 }" />
|
||||
<div class="bar-seg lost" :style="{ flex: stats.lost || 0.001 }" />
|
||||
<div class="bar-seg pending" :style="{ flex: stats.pending || 0.001 }" />
|
||||
<div class="bar-seg push" :style="{ flex: stats.push || 0.001 }" />
|
||||
<div class="stats-bar" :class="{ empty: stats.total === 0 }" aria-hidden="true">
|
||||
<template v-if="stats.total > 0">
|
||||
<div v-if="stats.won > 0" class="bar-seg won" :style="{ flex: stats.won }" />
|
||||
<div v-if="stats.lost > 0" class="bar-seg lost" :style="{ flex: stats.lost }" />
|
||||
<div v-if="stats.pending > 0" class="bar-seg pending" :style="{ flex: stats.pending }" />
|
||||
<div v-if="stats.push > 0" class="bar-seg push" :style="{ flex: stats.push }" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="stats-row secondary">
|
||||
<div class="stat-item">
|
||||
@@ -141,8 +143,11 @@ const stats = computed(() => {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stats-bar.empty {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.bar-seg {
|
||||
min-width: 2px;
|
||||
border-radius: 2px;
|
||||
transition: flex 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,18 @@ const props = defineProps<{
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: import('../utils/matchPhase').MatchPhase;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
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;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { teamFlagUrl } from '../utils/teamFlag';
|
||||
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
match: {
|
||||
@@ -17,10 +18,18 @@ const props = defineProps<{
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: MatchPhase;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
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;
|
||||
};
|
||||
}>();
|
||||
@@ -30,16 +39,10 @@ const emit = defineEmits<{ bet: [id: string] }>();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
function formatKickoff(startTime: string) {
|
||||
const d = new Date(startTime);
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
const timeStr = d.toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit' });
|
||||
if (isToday) return `${t('bet.today')} ${timeStr}`;
|
||||
const dateStr = d.toLocaleDateString(locale.value, { month: 'numeric', day: 'numeric' });
|
||||
return `${dateStr} ${timeStr}`;
|
||||
return formatLocalMatchDateTime(startTime, locale.value, {
|
||||
variant: 'compact',
|
||||
todayLabel: t('bet.today'),
|
||||
});
|
||||
}
|
||||
|
||||
const kickoffText = computed(() => formatKickoff(props.match.startTime));
|
||||
@@ -64,7 +67,7 @@ const phaseLabel = computed(() => matchPhaseLabel(t, phase.value));
|
||||
|
||||
const liveScoreText = computed(() => {
|
||||
const s = props.match.score;
|
||||
if (!s) return '';
|
||||
if (!s || s.ftHome == null || s.ftAway == null) return '';
|
||||
return `${s.ftHome} - ${s.ftAway}`;
|
||||
});
|
||||
</script>
|
||||
@@ -216,8 +219,11 @@ const liveScoreText = computed(() => {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,12 @@ const props = withDefaults(
|
||||
{ size: 'md' },
|
||||
);
|
||||
|
||||
const useCustomLogo = computed(() => Boolean(props.logoUrl?.trim()));
|
||||
function isCustomLogo(url?: string | null) {
|
||||
const value = url?.trim();
|
||||
return Boolean(value && !value.includes('flagcdn.com'));
|
||||
}
|
||||
|
||||
const useCustomLogo = computed(() => isCustomLogo(props.logoUrl));
|
||||
const src = computed(() =>
|
||||
teamFlagUrl(props.teamCode, props.teamName, props.logoUrl),
|
||||
);
|
||||
|
||||
@@ -9,27 +9,34 @@ const props = defineProps<{
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
selectionDisplayName?: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}>;
|
||||
stakes: Record<string, number>;
|
||||
isSelected: (id: string) => boolean;
|
||||
locked?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:stakes': [Record<string, number>];
|
||||
pick: [id: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const columns = computed(() =>
|
||||
groupCorrectScoreSelections(props.selections, props.marketType, t),
|
||||
groupCorrectScoreSelections(
|
||||
props.selections.map((s) => ({
|
||||
...s,
|
||||
selectionName: s.selectionDisplayName?.trim() || s.selectionName,
|
||||
})),
|
||||
props.marketType,
|
||||
t,
|
||||
),
|
||||
);
|
||||
|
||||
function setStake(sel: CsSelection, raw: string) {
|
||||
function onPick(sel: CsSelection) {
|
||||
if (props.locked) return;
|
||||
const n = Math.max(0, Number(raw) || 0);
|
||||
emit('update:stakes', { ...props.stakes, [sel.id]: n });
|
||||
emit('pick', sel.id);
|
||||
}
|
||||
|
||||
function formatOdds(odds: string) {
|
||||
@@ -48,21 +55,18 @@ function formatOdds(odds: string) {
|
||||
|
||||
<div class="cols-grid">
|
||||
<div v-for="colKey in ['home', 'draw', 'away'] as const" :key="colKey" class="col">
|
||||
<div v-for="sel in columns[colKey]" :key="sel.id" class="score-card">
|
||||
<button
|
||||
v-for="sel in columns[colKey]"
|
||||
:key="sel.id"
|
||||
type="button"
|
||||
class="score-card"
|
||||
:class="{ selected: isSelected(sel.id), 'score-card--locked': locked }"
|
||||
:disabled="locked"
|
||||
@click="onPick(sel)"
|
||||
>
|
||||
<span class="score-line">{{ sel.scoreDisplay }}</span>
|
||||
<span class="odds">{{ formatOdds(sel.odds) }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="stake-input"
|
||||
min="0"
|
||||
step="1"
|
||||
inputmode="decimal"
|
||||
:placeholder="t('bet.stake_placeholder')"
|
||||
:value="stakes[sel.id] ?? ''"
|
||||
:disabled="locked"
|
||||
@input="setStake(sel, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +81,7 @@ function formatOdds(odds: string) {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.cs-panel--locked .stake-input {
|
||||
.cs-panel--locked .score-card {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -108,10 +112,29 @@ function formatOdds(odds: string) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 3px;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
min-height: 42px;
|
||||
padding: 6px 3px;
|
||||
background: #161616;
|
||||
border: 1px solid #282828;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.score-card.selected {
|
||||
border-color: var(--primary);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.score-card--locked {
|
||||
background: #111;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.score-card--locked .score-line,
|
||||
.score-card--locked .odds {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.score-line {
|
||||
@@ -125,27 +148,4 @@ function formatOdds(odds: string) {
|
||||
font-weight: 700;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.stake-input {
|
||||
width: 100%;
|
||||
padding: 4px 2px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: #0a0a0a;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.stake-input:focus {
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.stake-input::-webkit-outer-spin-button,
|
||||
.stake-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,19 +7,24 @@ const props = defineProps<{
|
||||
id: string;
|
||||
selectionCode?: string;
|
||||
selectionName: string;
|
||||
selectionDisplayName?: string;
|
||||
odds: string;
|
||||
}[];
|
||||
isSelected: (id: string) => boolean;
|
||||
compact?: boolean;
|
||||
locked?: boolean;
|
||||
lineValue?: string | number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ pick: [id: string] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
function label(sel: (typeof props.selections)[number]) {
|
||||
if (sel.selectionDisplayName?.trim()) return sel.selectionDisplayName;
|
||||
if (sel.selectionCode) {
|
||||
return resolveSelectionLabel(t, sel.selectionCode, sel.selectionName);
|
||||
return resolveSelectionLabel(t, sel.selectionCode, sel.selectionName, {
|
||||
lineValue: props.lineValue,
|
||||
});
|
||||
}
|
||||
return sel.selectionName;
|
||||
}
|
||||
|
||||
@@ -1,801 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBetSlipStore } from '../../stores/betSlip';
|
||||
import { usePlayerMatches, type ParlayMatch } from '../../composables/usePlayerMatches';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
|
||||
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS, PARLAY_MARKET_GROUPS } from '../../utils/parlayColumns';
|
||||
import BetGuideHelp from '../BetGuideHelp.vue';
|
||||
import GoldSpinner from '../GoldSpinner.vue';
|
||||
import TeamEmblem from '../TeamEmblem.vue';
|
||||
import cardBg from '../../assets/images/card-bg.webp';
|
||||
import { matchPhaseLabel, type MatchPhase } from '../../utils/matchPhase';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
||||
|
||||
type TimeFilter = 'all' | 'today';
|
||||
|
||||
interface Selection {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}
|
||||
|
||||
interface Market {
|
||||
id: string;
|
||||
marketType: string;
|
||||
lineValue?: string | number | null;
|
||||
allowParlay?: boolean;
|
||||
selections: Selection[];
|
||||
}
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
function goLogin() {
|
||||
auth.showLoginPrompt('/bet');
|
||||
}
|
||||
|
||||
const { parlayMatches, parlayLoading, loadParlay } = usePlayerMatches();
|
||||
const matches = parlayMatches;
|
||||
const loading = parlayLoading;
|
||||
const timeFilter = ref<TimeFilter>('all');
|
||||
const leagueFilter = ref('');
|
||||
const showClosed = ref(false);
|
||||
const collapsed = ref<Set<string>>(new Set());
|
||||
|
||||
const parlayMarketKeys = PARLAY_MARKET_TYPES.map((c) => c.key);
|
||||
|
||||
function syncCollapsedAfterLoad() {
|
||||
const ids = matches.value.map((m) => m.id);
|
||||
// 只保留仍然存在的 id
|
||||
const kept = [...collapsed.value].filter((id) => ids.includes(id));
|
||||
if (kept.length > 0) {
|
||||
collapsed.value = new Set(kept);
|
||||
return;
|
||||
}
|
||||
// 默认只展开第一个,其余折叠
|
||||
if (ids.length > 1) {
|
||||
collapsed.value = new Set(ids.slice(1));
|
||||
} else {
|
||||
collapsed.value = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
useOnLocaleChange(async () => {
|
||||
await loadParlay(true);
|
||||
syncCollapsedAfterLoad();
|
||||
});
|
||||
|
||||
const leagues = computed(() => {
|
||||
const seen = new Set<string>();
|
||||
const list: { id: string; name: string }[] = [];
|
||||
for (const m of matches.value) {
|
||||
const id = m.leagueId ?? m.leagueName;
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
list.push({ id, name: m.leagueName });
|
||||
}
|
||||
}
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return list;
|
||||
});
|
||||
|
||||
function parseLine(v: string | number | null | undefined) {
|
||||
if (v == null || v === '') return null;
|
||||
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function isParlayEligibleMarket(market: Market) {
|
||||
if (!market.selections.length) return false;
|
||||
return canSelectForParlay({
|
||||
marketType: market.marketType,
|
||||
lineValue: parseLine(market.lineValue),
|
||||
allowParlay: market.allowParlay ?? true,
|
||||
}).ok;
|
||||
}
|
||||
|
||||
function hasParlayMarkets(m: ParlayMatch) {
|
||||
return parlayMarketKeys.some((key) => {
|
||||
const market = m.markets?.find((mk) => mk.marketType === key);
|
||||
return market && isParlayEligibleMarket(market);
|
||||
});
|
||||
}
|
||||
|
||||
function getMarket(m: ParlayMatch, marketType: string) {
|
||||
return m.markets?.find((mk) => mk.marketType === marketType);
|
||||
}
|
||||
|
||||
function dayStart(d: Date) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function isKickoffToday(startTime: string) {
|
||||
const kick = new Date(startTime);
|
||||
const now = new Date();
|
||||
const start = dayStart(now);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return kick >= start && kick < end;
|
||||
}
|
||||
|
||||
const filteredMatches = computed(() => {
|
||||
let list = matches.value;
|
||||
if (timeFilter.value === 'today') {
|
||||
list = list.filter((m) => isKickoffToday(m.startTime));
|
||||
}
|
||||
if (leagueFilter.value) {
|
||||
list = list.filter((m) => (m.leagueId ?? m.leagueName) === leagueFilter.value);
|
||||
}
|
||||
if (!showClosed.value) {
|
||||
list = list.filter((m) => {
|
||||
const phase = m.matchPhase ?? (m.bettingOpen === false ? 'closed_pending' : 'open');
|
||||
return phase === 'open' || phase === undefined;
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function formatKickoff(startTime: string) {
|
||||
return new Date(startTime).toLocaleString(locale.value, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatOdds(odds: string) {
|
||||
const n = parseFloat(odds);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : odds;
|
||||
}
|
||||
|
||||
function selLabel(sel: Selection) {
|
||||
const key = PARLAY_SELECTION_KEYS[sel.selectionCode];
|
||||
if (key) return t(`bet.${key}`);
|
||||
return sel.selectionName.slice(0, 2);
|
||||
}
|
||||
|
||||
function colLabel(labelKey: string) {
|
||||
return t(`bet.${labelKey}`);
|
||||
}
|
||||
|
||||
function isPicked(selectionId: string) {
|
||||
return slip.items.some((i) => i.selectionId === selectionId);
|
||||
}
|
||||
|
||||
const parlayHint = ref('');
|
||||
|
||||
function pickSelection(match: ParlayMatch, market: Market, sel: Selection) {
|
||||
if (match.bettingOpen === false) return;
|
||||
const err = slip.addParlayLeg({
|
||||
selectionId: sel.id,
|
||||
oddsVersion: String(sel.oddsVersion),
|
||||
matchId: match.id,
|
||||
matchName: `${match.homeTeamName} vs ${match.awayTeamName}`,
|
||||
selectionName: `${selLabel(sel)} ${formatOdds(sel.odds)}`,
|
||||
odds: parseFloat(sel.odds),
|
||||
marketType: market.marketType,
|
||||
lineValue: parseLine(market.lineValue),
|
||||
allowParlay: market.allowParlay,
|
||||
});
|
||||
if (err === 'MAX_LEGS') parlayHint.value = t('bet.parlay_max_legs');
|
||||
else if (err === 'QUARTER_LINE') parlayHint.value = t('bet.parlay_block_quarter');
|
||||
else if (err === 'OUTRIGHT') parlayHint.value = t('bet.parlay_block_outright');
|
||||
else if (err === 'NOT_ALLOWED') parlayHint.value = t('bet.parlay_block_not_allowed');
|
||||
else if (err === 'SAME_MATCH') parlayHint.value = t('bet.parlay_same_match');
|
||||
else parlayHint.value = '';
|
||||
}
|
||||
|
||||
function openSlip() {
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
slip.openDrawer();
|
||||
}
|
||||
|
||||
const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
|
||||
const footConfirmLabel = computed(() =>
|
||||
slip.canPlaceParlay ? t('bet.parlay_confirm_parlay') : t('bet.cs_confirm_cell'),
|
||||
);
|
||||
|
||||
function toggleCollapse(id: string) {
|
||||
const next = new Set(collapsed.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
collapsed.value = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="parlay-panel" :class="{ 'has-fixed-foot': showParlayFoot }">
|
||||
<div class="toolbar">
|
||||
<select v-model="timeFilter" class="filter-select">
|
||||
<option value="all">{{ t('bet.parlay_filter_all') }}</option>
|
||||
<option value="today">{{ t('bet.tab_today') }}</option>
|
||||
</select>
|
||||
<select v-model="leagueFilter" class="filter-select">
|
||||
<option value="">{{ t('bet.parlay_filter_all') }}</option>
|
||||
<option v-for="lg in leagues" :key="lg.id" :value="lg.id">{{ lg.name }}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="phase-toggle"
|
||||
:class="{ 'phase-toggle--active': showClosed }"
|
||||
@click="showClosed = !showClosed"
|
||||
>
|
||||
{{ showClosed ? t('bet.show_all_matches') : t('bet.show_open_only') }}
|
||||
</button>
|
||||
<BetGuideHelp
|
||||
:title="t('bet.parlay_guide_title')"
|
||||
:aria-label="t('bet.parlay_guide_help')"
|
||||
storage-key="thebet365_parlay_guide_seen"
|
||||
>
|
||||
<p class="intro">{{ t('bet.parlay_desc') }}</p>
|
||||
<ol>
|
||||
<li>{{ t('bet.parlay_guide_1') }}</li>
|
||||
<li>{{ t('bet.parlay_guide_2') }}</li>
|
||||
<li>{{ t('bet.parlay_guide_3') }}</li>
|
||||
</ol>
|
||||
<p class="rules-link">{{ t('bet.guide_rules_link') }}</p>
|
||||
</BetGuideHelp>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredMatches.length" class="match-list">
|
||||
<div
|
||||
v-for="match in filteredMatches"
|
||||
:key="match.id"
|
||||
class="match-card"
|
||||
:class="{
|
||||
collapsed: collapsed.has(match.id),
|
||||
'match-card--phase': (match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) !== 'open',
|
||||
}"
|
||||
>
|
||||
<button type="button" class="match-head" @click="toggleCollapse(match.id)">
|
||||
<span
|
||||
v-if="(match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) === 'settled'"
|
||||
class="status-tag status-tag--settled"
|
||||
>{{ matchPhaseLabel(t, 'settled') }}</span>
|
||||
<span
|
||||
v-else-if="(match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) === 'closed_pending'"
|
||||
class="status-tag status-tag--pending"
|
||||
>{{ matchPhaseLabel(t, 'closed_pending') }}</span>
|
||||
|
||||
<div class="match-head-top">
|
||||
<span class="toggle-icon" :class="{ open: !collapsed.has(match.id) }" aria-hidden="true">
|
||||
<span class="toggle-mark">{{ collapsed.has(match.id) ? '+' : '−' }}</span>
|
||||
</span>
|
||||
<span class="m-league">{{ match.leagueName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="match-head-teams">
|
||||
<div class="m-team home">
|
||||
<TeamEmblem
|
||||
size="sm"
|
||||
:team-code="match.homeTeamCode"
|
||||
:team-name="match.homeTeamName"
|
||||
:logo-url="match.homeTeamLogoUrl"
|
||||
/>
|
||||
<span class="m-name">{{ match.homeTeamName }}</span>
|
||||
</div>
|
||||
<div class="m-center">
|
||||
<span
|
||||
v-if="(match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) !== 'open' && match.score"
|
||||
class="m-score"
|
||||
>
|
||||
{{ match.score.ftHome }} - {{ match.score.ftAway }}
|
||||
</span>
|
||||
<span v-else class="m-time">{{ formatKickoff(match.startTime) }}</span>
|
||||
</div>
|
||||
<div class="m-team away">
|
||||
<span class="m-name">{{ match.awayTeamName }}</span>
|
||||
<TeamEmblem
|
||||
size="sm"
|
||||
:team-code="match.awayTeamCode"
|
||||
:team-name="match.awayTeamName"
|
||||
:logo-url="match.awayTeamLogoUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="!collapsed.has(match.id)" class="market-blocks">
|
||||
<div v-for="group in PARLAY_MARKET_GROUPS" :key="group.headerKey" class="market-group">
|
||||
<div class="group-header">{{ colLabel(group.headerKey) }}</div>
|
||||
<div
|
||||
v-for="col in group.columns"
|
||||
:key="col.key"
|
||||
class="market-row"
|
||||
>
|
||||
<span class="block-label">{{ colLabel(col.labelKey) }}</span>
|
||||
<div class="block-btns">
|
||||
<template
|
||||
v-if="
|
||||
getMarket(match, col.key) &&
|
||||
isParlayEligibleMarket(getMarket(match, col.key)!)
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-for="sel in getMarket(match, col.key)!.selections"
|
||||
:key="sel.id"
|
||||
type="button"
|
||||
class="odd-btn"
|
||||
:class="{
|
||||
picked: isPicked(sel.id),
|
||||
'odd-btn--locked': match.bettingOpen === false,
|
||||
}"
|
||||
:disabled="match.bettingOpen === false"
|
||||
@click="pickSelection(match, getMarket(match, col.key)!, sel)"
|
||||
>
|
||||
<span class="odd-label">{{ selLabel(sel) }}</span>
|
||||
<span class="odd-val">{{ formatOdds(sel.odds) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<span v-else class="market-empty">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<span class="empty-icon" aria-hidden="true">⚽</span>
|
||||
<p>{{ t('bet.parlay_empty') }}</p>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showParlayFoot" class="parlay-foot-fixed">
|
||||
<p v-if="parlayHint" class="foot-hint foot-hint--warn">{{ parlayHint }}</p>
|
||||
<p v-else-if="slip.count < 2" class="foot-hint">{{ t('bet.parlay_need_more') }}</p>
|
||||
<p v-else-if="slip.count > PARLAY_MAX_LEGS" class="foot-hint">{{ t('bet.parlay_max_legs') }}</p>
|
||||
<p v-else class="foot-meta">
|
||||
{{ t('bet.bet_slip') }} ({{ slip.count }}) · {{ t('bet.parlay') }}
|
||||
{{ slip.totalOdds.toFixed(2) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="market-foot-btn"
|
||||
:disabled="!slip.canPlaceParlay"
|
||||
@click="openSlip"
|
||||
>
|
||||
{{ footConfirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.parlay-panel {
|
||||
padding: 0 12px 16px;
|
||||
}
|
||||
|
||||
.parlay-panel.has-fixed-foot {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
background: #141414;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
color: var(--primary-light);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.phase-toggle {
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
background: #141414;
|
||||
border: 1px solid #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phase-toggle--active {
|
||||
color: var(--primary-light);
|
||||
border-color: var(--primary);
|
||||
background: rgba(200, 168, 78, 0.08);
|
||||
}
|
||||
|
||||
.parlay-foot-fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(50px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 95;
|
||||
padding: 10px 12px 10px;
|
||||
background: rgba(14, 14, 14, 0.98);
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.foot-hint,
|
||||
.foot-meta {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.foot-hint--warn {
|
||||
color: var(--danger);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.foot-meta {
|
||||
color: var(--primary-light);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.market-foot-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.market-foot-btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.match-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.match-card {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
|
||||
border: 1px solid rgba(140, 140, 140, 0.35);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.match-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.match-card.collapsed {
|
||||
border-color: #222;
|
||||
}
|
||||
|
||||
.match-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.match-head::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
|
||||
opacity: 0.25;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.match-head:active {
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
.match-head-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.match-head-teams {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-team {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-team.away {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 50px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.m-name {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m-vs {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #555;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.m-time {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #141414;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toggle-icon.open {
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.toggle-mark {
|
||||
color: var(--primary-light);
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.m-league {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.match-head :deep(.team-emblem) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.market-blocks {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 10px;
|
||||
border-top: 1px solid rgba(140, 140, 140, 0.15);
|
||||
}
|
||||
|
||||
.market-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px 6px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #888;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0 2px 2px;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
}
|
||||
|
||||
.market-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.block-btns {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.odd-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #333;
|
||||
min-width: 44px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.odd-btn.picked {
|
||||
border-color: var(--primary);
|
||||
background: rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.odd-btn--locked {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.odd-btn--locked .odd-label,
|
||||
.odd-btn--locked .odd-val {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.match-card--phase {
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
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--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);
|
||||
}
|
||||
|
||||
.m-score {
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.odd-label {
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.odd-val {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.market-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
color: #444;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.state,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -20,40 +20,25 @@ export interface PlayerMatchSummary {
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: MatchPhase;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ParlayMarketSelection {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}
|
||||
|
||||
export interface ParlayMarket {
|
||||
id: string;
|
||||
marketType: string;
|
||||
lineValue?: string | number | null;
|
||||
allowParlay?: boolean;
|
||||
selections: ParlayMarketSelection[];
|
||||
}
|
||||
|
||||
export interface ParlayMatch extends PlayerMatchSummary {
|
||||
markets: ParlayMarket[];
|
||||
}
|
||||
|
||||
const summaryMatches = shallowRef<PlayerMatchSummary[]>([]);
|
||||
const parlayMatches = shallowRef<ParlayMatch[]>([]);
|
||||
const summaryLoading = ref(false);
|
||||
const parlayLoading = ref(false);
|
||||
|
||||
let summaryInflight: Promise<void> | null = null;
|
||||
let parlayInflight: Promise<void> | null = null;
|
||||
|
||||
async function loadSummary(force = false): Promise<void> {
|
||||
if (force) summaryMatches.value = [];
|
||||
@@ -76,74 +61,10 @@ async function loadSummary(force = false): Promise<void> {
|
||||
return summaryInflight;
|
||||
}
|
||||
|
||||
async function loadParlay(force = false): Promise<void> {
|
||||
if (force) parlayMatches.value = [];
|
||||
|
||||
const hadData = parlayMatches.value.length > 0;
|
||||
if (!force && hadData) {
|
||||
if (parlayInflight) return parlayInflight;
|
||||
parlayInflight = (async () => {
|
||||
try {
|
||||
const { data } = await api.get('/player/matches', { params: { scope: 'parlay' } });
|
||||
mergeParlayOdds((data.data ?? []) as ParlayMatch[]);
|
||||
} finally {
|
||||
parlayInflight = null;
|
||||
}
|
||||
})();
|
||||
return parlayInflight;
|
||||
}
|
||||
|
||||
if (parlayInflight) return parlayInflight;
|
||||
parlayLoading.value = true;
|
||||
|
||||
parlayInflight = (async () => {
|
||||
try {
|
||||
const { data } = await api.get('/player/matches', { params: { scope: 'parlay' } });
|
||||
parlayMatches.value = (data.data ?? []) as ParlayMatch[];
|
||||
} catch {
|
||||
parlayMatches.value = [];
|
||||
} finally {
|
||||
parlayLoading.value = false;
|
||||
parlayInflight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return parlayInflight;
|
||||
}
|
||||
|
||||
function mergeParlayOdds(fresh: ParlayMatch[]) {
|
||||
const matchMap = new Map<string, ParlayMatch>();
|
||||
for (const m of fresh) matchMap.set(m.id, m);
|
||||
|
||||
for (const match of parlayMatches.value) {
|
||||
const freshMatch = matchMap.get(match.id);
|
||||
if (!freshMatch) continue;
|
||||
const marketMap = new Map<string, ParlayMarket>();
|
||||
for (const mk of freshMatch.markets) marketMap.set(mk.id, mk);
|
||||
for (const market of match.markets) {
|
||||
const freshMarket = marketMap.get(market.id);
|
||||
if (!freshMarket) continue;
|
||||
const selMap = new Map<string, ParlayMarketSelection>();
|
||||
for (const s of freshMarket.selections) selMap.set(s.id, s);
|
||||
for (const sel of market.selections) {
|
||||
const fs = selMap.get(sel.id);
|
||||
if (fs) {
|
||||
sel.odds = fs.odds;
|
||||
sel.oddsVersion = fs.oddsVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 赛事列表与串关共享缓存,避免重复拉取大 JSON */
|
||||
export function usePlayerMatches() {
|
||||
return {
|
||||
summaryMatches,
|
||||
parlayMatches,
|
||||
summaryLoading,
|
||||
parlayLoading,
|
||||
loadSummary,
|
||||
loadParlay,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -242,7 +242,6 @@ export default {
|
||||
parlay: 'Parlay',
|
||||
tab_matches: 'Matches',
|
||||
tab_outright: 'Outright',
|
||||
tab_parlay: 'Parlay',
|
||||
tab_today: 'Today',
|
||||
tab_early: 'Early',
|
||||
show_open_only: 'Open only',
|
||||
@@ -271,23 +270,10 @@ export default {
|
||||
outright_shown_count: '{shown} / {total} teams shown',
|
||||
outright_load_more: 'Load more',
|
||||
cancel: 'Cancel',
|
||||
parlay_title: 'Parlay',
|
||||
parlay_guide_title: 'How to parlay',
|
||||
parlay_guide_help: 'Parlay help',
|
||||
parlay_desc: 'Combine 2–5 pre-match legs (2-fold to 5-fold). No live, outright, or quarter-ball HDP/O-U in parlay.',
|
||||
parlay_guide_1: 'Tap odds in the list; selected cells show a gold border. Tap again to remove',
|
||||
parlay_guide_2: 'Pick 2–5 legs from different matches. No outright or quarter-ball HDP/O-U',
|
||||
parlay_guide_3: 'Tap Confirm order at the bottom, enter stake in the bet slip, and submit',
|
||||
parlay_max_legs: 'Parlay allows up to 5 legs',
|
||||
parlay_block_outright: 'Outright cannot be parlayed',
|
||||
parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed',
|
||||
parlay_block_not_allowed: 'This market cannot be parlayed',
|
||||
parlay_filter_all: 'All',
|
||||
parlay_empty: 'No matches available for parlay betting',
|
||||
parlay_same_match: 'Cannot parlay selections from the same match',
|
||||
parlay_same_match_singles: '{n} selection(s) → {n} separate single bet(s)',
|
||||
parlay_confirm_singles: 'Place {n} single bet(s)',
|
||||
parlay_confirm_parlay: 'Place parlay',
|
||||
parlay_need_more: 'Select at least 2 legs for parlay',
|
||||
back: 'Back',
|
||||
refresh: 'Refresh',
|
||||
@@ -309,10 +295,6 @@ export default {
|
||||
market_ht_handicap: 'HT Handicap',
|
||||
market_ht_ou: 'HT O/U',
|
||||
market_ht_1x2: 'HT 1X2',
|
||||
parlay_lbl_handicap: 'Handicap',
|
||||
parlay_lbl_ou: 'O/U',
|
||||
parlay_lbl_1x2: '1X2',
|
||||
parlay_lbl_oe: 'Odd/Even',
|
||||
parlay_sel_home: 'H',
|
||||
parlay_sel_away: 'A',
|
||||
parlay_sel_draw: 'D',
|
||||
@@ -326,26 +308,32 @@ export default {
|
||||
col_home: 'Home',
|
||||
col_draw: 'Draw',
|
||||
col_away: 'Away',
|
||||
cs_stake_required: 'Enter stake on at least one score',
|
||||
cs_stake_required: 'Select at least one score',
|
||||
cs_confirm_title: 'Confirm correct score bets',
|
||||
cs_confirm_count: '{n} bet(s)',
|
||||
cs_confirm_total_stake: 'Total stake',
|
||||
cs_place_success: 'Bet placed',
|
||||
cs_place_failed: 'Bet failed',
|
||||
kickoff_time: 'Kickoff: ',
|
||||
result_stats_title: 'Match facts',
|
||||
result_ht: 'HT',
|
||||
result_ft: 'FT',
|
||||
result_corners: 'Corners',
|
||||
result_yellow_cards: 'Yellow',
|
||||
result_red_cards: 'Red',
|
||||
guide_title: 'How to bet',
|
||||
guide_help_aria: 'Betting help',
|
||||
guide_got_it: 'Got it',
|
||||
guide_flow_normal: 'Handicap / O-U / 1X2 etc.',
|
||||
guide_normal_1: 'Tap Expand to show odds',
|
||||
guide_normal_2: 'Tap one odds to select (gold border); tap again to cancel',
|
||||
guide_normal_3: 'Tap Place order under that market, enter stake and confirm',
|
||||
guide_normal_1: 'All markets are shown by default',
|
||||
guide_normal_2: 'Tap any odds to open the bet drawer',
|
||||
guide_normal_3: 'Enter stake in the drawer, then place a single bet or add it to parlay',
|
||||
guide_flow_cs: 'Correct score',
|
||||
guide_cs_1: 'Expand and enter stake on each score',
|
||||
guide_cs_2: 'Enter stakes, then tap Place order at the bottom of that market',
|
||||
guide_cs_3: 'Multiple scores = multiple bets',
|
||||
guide_cs_1: 'Tap the score you want in the score table',
|
||||
guide_cs_2: 'The bet drawer opens so you can enter stake and submit',
|
||||
guide_cs_3: 'Score picks are single bets and can also be added to parlay',
|
||||
guide_flow_parlay: 'Parlay (2–5 legs)',
|
||||
guide_parlay_1: 'This page is for singles and correct score. Parlay: tap Bet in the bottom nav, then the Parlay tab at the top of that page, pick 2–5 different matches, and submit from the bet slip.',
|
||||
guide_parlay_1: 'Tap any odds to open the drawer, then tap Add to parlay to put the current selection into your parlay slip.',
|
||||
guide_rules_link: 'Full rules: Profile → Betting Rules.',
|
||||
mode_cs_tag: 'Bet here',
|
||||
mode_slip_tag: 'Add to slip',
|
||||
@@ -360,12 +348,22 @@ export default {
|
||||
slip_bar_ready: '1 selection',
|
||||
slip_bar_go: 'Bet slip',
|
||||
cs_top_hint: '① Enter stake ② Tap Confirm bet above',
|
||||
slip_empty_hint: 'Tap odds to add to bet slip',
|
||||
slip_empty_hint: 'Tap odds to open the bet drawer',
|
||||
slip_remove: 'Remove',
|
||||
slip_singles_hint: '{n} single bet(s). Parlay: Bet page → top Parlay tab.',
|
||||
slip_stake_per_bet: 'Stake per bet',
|
||||
slip_est_return: 'Est. total return',
|
||||
slip_est_return: 'Est. max payout',
|
||||
slip_parlay_odds: 'Combined odds {odds}',
|
||||
slip_review_title: 'Review selection',
|
||||
slip_balance: 'Account balance',
|
||||
slip_tab_single: 'Single',
|
||||
slip_tab_parlay: 'Parlay',
|
||||
slip_parlay_empty_hint: 'Pick a selection, then tap Add to parlay',
|
||||
slip_add_parlay: 'Add to parlay',
|
||||
slip_parlay_same_match: 'Parlay selections cannot be from the same match. Remove the same-match leg first.',
|
||||
slip_parlay_count: '{n} parlay leg(s)',
|
||||
slip_total_stake: 'Total stake',
|
||||
slip_currency: 'Amount',
|
||||
slip_min: 'Min',
|
||||
slip_min_error: 'Minimum stake is {amount}',
|
||||
place_success: 'Bet placed',
|
||||
place_failed: 'Bet failed',
|
||||
},
|
||||
@@ -411,7 +409,7 @@ export default {
|
||||
password_disabled: 'Password change is disabled for this account; contact support',
|
||||
rules_title: 'Betting Rules',
|
||||
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
|
||||
rules_p2: 'Parlays: 2–5 legs from different matches (one per match). Outright and quarter-ball HDP/O-U are excluded.',
|
||||
rules_p2: 'Parlays: 2–5 legs, with at most one selection per match. Outright and quarter-ball HDP/O-U are excluded.',
|
||||
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
|
||||
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
|
||||
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
|
||||
|
||||
@@ -248,7 +248,6 @@ export default {
|
||||
parlay: 'Berganda',
|
||||
tab_matches: 'Perlawanan',
|
||||
tab_outright: 'Juara',
|
||||
tab_parlay: 'Berganda',
|
||||
tab_today: 'Hari Ini',
|
||||
tab_early: 'Awal',
|
||||
show_open_only: 'Buka sahaja',
|
||||
@@ -277,23 +276,10 @@ export default {
|
||||
outright_shown_count: '{shown} / {total} pasukan dipaparkan',
|
||||
outright_load_more: 'Muat lagi',
|
||||
cancel: 'Batal',
|
||||
parlay_title: 'Pertaruhan Berganda',
|
||||
parlay_guide_title: 'Cara parlay',
|
||||
parlay_guide_help: 'Bantuan parlay',
|
||||
parlay_desc: 'Gabung 2–5 perlawanan pra-perlawanan (2 hingga 5 liputan). Tiada live, outright atau suku bola HDP/O-U.',
|
||||
parlay_guide_1: 'Ketik odds dalam senarai; pilihan dipilih ada sempadan emas. Ketik lagi untuk batal',
|
||||
parlay_guide_2: 'Pilih 2–5 pilihan dari perlawanan berbeza. Tiada outright atau suku bola HDP/O-U',
|
||||
parlay_guide_3: 'Ketik Sahkan pesanan di bawah, isi pegangan dalam slip, dan hantar',
|
||||
parlay_max_legs: 'Maksimum 5 pilihan parlay',
|
||||
parlay_block_outright: 'Outright tidak boleh parlay',
|
||||
parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay',
|
||||
parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay',
|
||||
parlay_filter_all: 'Semua',
|
||||
parlay_empty: 'Tiada perlawanan untuk pertaruhan berganda',
|
||||
parlay_same_match: 'Perlawanan sama tidak boleh berganda',
|
||||
parlay_same_match_singles: '{n} pilihan → {n} pertaruhan tunggal berasingan',
|
||||
parlay_confirm_singles: 'Sahkan {n} pertaruhan tunggal',
|
||||
parlay_confirm_parlay: 'Sahkan parlay',
|
||||
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
|
||||
back: 'Kembali',
|
||||
refresh: 'Muat semula',
|
||||
@@ -315,10 +301,6 @@ export default {
|
||||
market_ht_handicap: 'Handicap Separuh',
|
||||
market_ht_ou: 'Atas/Bawah Separuh',
|
||||
market_ht_1x2: '1X2 Separuh',
|
||||
parlay_lbl_handicap: 'Handicap',
|
||||
parlay_lbl_ou: 'Atas/Bawah',
|
||||
parlay_lbl_1x2: '1X2',
|
||||
parlay_lbl_oe: 'Ganjil/Genap',
|
||||
parlay_sel_home: 'R',
|
||||
parlay_sel_away: 'P',
|
||||
parlay_sel_draw: 'S',
|
||||
@@ -332,26 +314,32 @@ export default {
|
||||
col_home: 'Home',
|
||||
col_draw: 'Seri',
|
||||
col_away: 'Away',
|
||||
cs_stake_required: 'Masukkan jumlah pada sekurang-kurangnya satu skor',
|
||||
cs_stake_required: 'Pilih sekurang-kurangnya satu skor',
|
||||
cs_confirm_title: 'Sahkan pertaruhan skor tepat',
|
||||
cs_confirm_count: '{n} pertaruhan',
|
||||
cs_confirm_total_stake: 'Jumlah pertaruhan',
|
||||
cs_place_success: 'Pertaruhan berjaya',
|
||||
cs_place_failed: 'Pertaruhan gagal',
|
||||
kickoff_time: 'Masa mula: ',
|
||||
result_stats_title: 'Statistik keputusan',
|
||||
result_ht: 'Separuh',
|
||||
result_ft: 'Penuh',
|
||||
result_corners: 'Sudut',
|
||||
result_yellow_cards: 'Kuning',
|
||||
result_red_cards: 'Merah',
|
||||
guide_title: 'Cara pertaruhan',
|
||||
guide_help_aria: 'Bantuan pertaruhan',
|
||||
guide_got_it: 'Faham',
|
||||
guide_flow_normal: 'Handicap / O-U / 1X2',
|
||||
guide_normal_1: 'Ketik Kembang untuk lihat odds',
|
||||
guide_normal_2: 'Pilih satu odds (sisi emas); ketik lagi untuk batal',
|
||||
guide_normal_3: 'Ketik Sahkan pesanan di bawah pasaran, isi jumlah dan sahkan',
|
||||
guide_normal_1: 'Semua pasaran dipaparkan secara lalai',
|
||||
guide_normal_2: 'Ketik mana-mana odds untuk buka laci pertaruhan',
|
||||
guide_normal_3: 'Isi jumlah dalam laci, kemudian hantar tunggal atau tambah ke parlay',
|
||||
guide_flow_cs: 'Skor tepat',
|
||||
guide_cs_1: 'Kembang dan isi jumlah setiap skor',
|
||||
guide_cs_2: 'Isi jumlah, kemudian Sahkan pesanan di bawah pasaran itu',
|
||||
guide_cs_3: 'Beberapa skor = beberapa pertaruhan',
|
||||
guide_cs_1: 'Ketik skor yang mahu dipertaruh dalam jadual skor',
|
||||
guide_cs_2: 'Laci pertaruhan akan dibuka untuk isi jumlah dan hantar',
|
||||
guide_cs_3: 'Pilihan skor ialah tunggal dan boleh ditambah ke parlay',
|
||||
guide_flow_parlay: 'Parlay (2–5 perlawanan)',
|
||||
guide_parlay_1: 'Halaman ini untuk tunggal dan skor tepat. Parlay: ketik Pertaruhan di nav bawah, kemudian tab Parlay di bahagian atas halaman, pilih 2–5 perlawanan berbeza, hantar dari slip.',
|
||||
guide_parlay_1: 'Ketik mana-mana odds untuk buka laci, kemudian ketik Tambah ke parlay untuk masukkan pilihan ini ke slip parlay.',
|
||||
guide_rules_link: 'Peraturan penuh: Profil → Peraturan Pertaruhan.',
|
||||
mode_cs_tag: 'Pertaruhan di sini',
|
||||
mode_slip_tag: 'Tambah ke slip',
|
||||
@@ -366,12 +354,22 @@ export default {
|
||||
slip_bar_ready: '1 pilihan',
|
||||
slip_bar_go: 'Buka slip',
|
||||
cs_top_hint: '① Isi jumlah ② Ketik Sahkan di atas',
|
||||
slip_empty_hint: 'Ketik odds untuk tambah ke slip',
|
||||
slip_empty_hint: 'Ketik odds untuk buka laci pertaruhan',
|
||||
slip_remove: 'Buang',
|
||||
slip_singles_hint: '{n} pertaruhan tunggal. Parlay: halaman Pertaruhan → tab Berganda di atas.',
|
||||
slip_stake_per_bet: 'Jumlah setiap pertaruhan',
|
||||
slip_est_return: 'Anggaran pulangan',
|
||||
slip_est_return: 'Anggaran bayaran maksimum',
|
||||
slip_parlay_odds: 'Odds gabungan {odds}',
|
||||
slip_review_title: 'Semak pilihan',
|
||||
slip_balance: 'Baki akaun',
|
||||
slip_tab_single: 'Tunggal',
|
||||
slip_tab_parlay: 'Parlay',
|
||||
slip_parlay_empty_hint: 'Pilih satu odds, kemudian ketik Tambah ke parlay',
|
||||
slip_add_parlay: 'Tambah ke parlay',
|
||||
slip_parlay_same_match: 'Pilihan parlay tidak boleh daripada perlawanan sama. Buang pilihan perlawanan sama dahulu.',
|
||||
slip_parlay_count: '{n} pilihan parlay',
|
||||
slip_total_stake: 'Jumlah pertaruhan',
|
||||
slip_currency: 'Amaun',
|
||||
slip_min: 'Min',
|
||||
slip_min_error: 'Jumlah minimum ialah {amount}',
|
||||
place_success: 'Pertaruhan berjaya',
|
||||
place_failed: 'Pertaruhan gagal',
|
||||
},
|
||||
|
||||
@@ -242,7 +242,6 @@ export default {
|
||||
parlay: '串关',
|
||||
tab_matches: '球赛',
|
||||
tab_outright: '优胜冠军',
|
||||
tab_parlay: '串关投注',
|
||||
tab_today: '今日',
|
||||
tab_early: '早盘',
|
||||
show_open_only: '仅显示待开赛',
|
||||
@@ -271,23 +270,10 @@ export default {
|
||||
outright_shown_count: '已显示 {shown} / {total} 队',
|
||||
outright_load_more: '加载更多',
|
||||
cancel: '取消',
|
||||
parlay_title: '串关投注',
|
||||
parlay_guide_title: '串关怎么投?',
|
||||
parlay_guide_help: '查看串关说明',
|
||||
parlay_desc: '选择 2–5 场赛前赛事组合串关(2 串 1 至 5 串 1)。赔率相乘,不含滚球、冠军盘与四分盘让球/大小。',
|
||||
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
|
||||
parlay_guide_2: '须选 2–5 项,且须为不同赛事;冠军盘与四分盘让球/大小不可选',
|
||||
parlay_guide_3: '选好后点底部「确认下单」打开投注单,填写金额并提交',
|
||||
parlay_max_legs: '串关最多 5 项',
|
||||
parlay_block_outright: '冠军盘不可串关',
|
||||
parlay_block_quarter: '四分盘让球/大小不可串关',
|
||||
parlay_block_not_allowed: '该玩法不可串关',
|
||||
parlay_filter_all: '全部',
|
||||
parlay_empty: '暂无可用串关赛事',
|
||||
parlay_same_match: '同一场比赛不能串关',
|
||||
parlay_same_match_singles: '已选 {n} 项,将分 {n} 笔单关下单',
|
||||
parlay_confirm_singles: '确认下单({n}笔单关)',
|
||||
parlay_confirm_parlay: '确认串关下单',
|
||||
parlay_need_more: '请至少选择 2 项进行串关',
|
||||
back: '返回',
|
||||
refresh: '刷新',
|
||||
@@ -309,10 +295,6 @@ export default {
|
||||
market_ht_handicap: '半场 让球',
|
||||
market_ht_ou: '半场 大小',
|
||||
market_ht_1x2: '半场 独赢盘',
|
||||
parlay_lbl_handicap: '让球',
|
||||
parlay_lbl_ou: '大小',
|
||||
parlay_lbl_1x2: '独赢盘',
|
||||
parlay_lbl_oe: '单/双',
|
||||
parlay_sel_home: '主',
|
||||
parlay_sel_away: '客',
|
||||
parlay_sel_draw: '和',
|
||||
@@ -326,26 +308,32 @@ export default {
|
||||
col_home: '主场',
|
||||
col_draw: '平',
|
||||
col_away: '客场',
|
||||
cs_stake_required: '请至少在一个比分输入投注金额',
|
||||
cs_stake_required: '请至少选择一个比分',
|
||||
cs_confirm_title: '确认波胆下注',
|
||||
cs_confirm_count: '共 {n} 注',
|
||||
cs_confirm_total_stake: '总投注额',
|
||||
cs_place_success: '下注成功',
|
||||
cs_place_failed: '下注失败',
|
||||
kickoff_time: '开赛时间:',
|
||||
result_stats_title: '赛果统计',
|
||||
result_ht: '半场',
|
||||
result_ft: '全场',
|
||||
result_corners: '角球',
|
||||
result_yellow_cards: '黄牌',
|
||||
result_red_cards: '红牌',
|
||||
guide_title: '怎么下注?',
|
||||
guide_help_aria: '查看下注说明',
|
||||
guide_got_it: '知道了',
|
||||
guide_flow_normal: '让球 / 大小 / 独赢等',
|
||||
guide_normal_1: '点「展开玩法」打开赔率',
|
||||
guide_normal_2: '点一项赔率选中(金边),再点同一项可取消',
|
||||
guide_normal_3: '选中后在当前玩法底部点「确认下单」填金额并提交',
|
||||
guide_normal_1: '页面会默认展示全部盘口',
|
||||
guide_normal_2: '点击任意赔率会直接打开投注抽屉',
|
||||
guide_normal_3: '在抽屉里填写金额,可单注下注或加入串关',
|
||||
guide_flow_cs: '波胆(猜比分)',
|
||||
guide_cs_1: '点「展开玩法」在表格里填各比分金额',
|
||||
guide_cs_2: '填好金额后点该玩法底部「确认下单」,核对后提交',
|
||||
guide_cs_3: '可一次填多个比分,会拆成多笔注单',
|
||||
guide_flow_parlay: '串关(2–5 场)',
|
||||
guide_parlay_1: '本页为单关/波胆。串关:底部导航点「投注」,在页面顶部切换到「串关投注」,选 2–5 场不同赛事后在投注单提交。',
|
||||
guide_cs_1: '在比分表中点击要下注的比分',
|
||||
guide_cs_2: '系统会打开投注抽屉,填写金额后提交',
|
||||
guide_cs_3: '比分项按单注下注,也可以加入串关',
|
||||
guide_flow_parlay: '串关(2–5 项)',
|
||||
guide_parlay_1: '点击赔率打开投注抽屉,再点「加入串关」即可把当前选项放入串关单。',
|
||||
guide_rules_link: '完整规则见「我的」→ 投注规则。',
|
||||
mode_cs_tag: '本页直接下注',
|
||||
mode_slip_tag: '加入投注单',
|
||||
@@ -360,12 +348,22 @@ export default {
|
||||
slip_bar_ready: '已选一项',
|
||||
slip_bar_go: '投注单',
|
||||
cs_top_hint: '① 在比分格填金额 ② 点上方「确认下注」',
|
||||
slip_empty_hint: '点击赔率加入投注单',
|
||||
slip_empty_hint: '点击赔率打开投注抽屉',
|
||||
slip_remove: '移除',
|
||||
slip_singles_hint: '共 {n} 笔单关(串关请到「投注」页顶部「串关投注」)',
|
||||
slip_stake_per_bet: '每笔投注金额',
|
||||
slip_est_return: '预计总返还',
|
||||
slip_est_return: '预计最大领取金额',
|
||||
slip_parlay_odds: '组合赔率 {odds}',
|
||||
slip_review_title: '确认下注内容',
|
||||
slip_balance: '账户余额',
|
||||
slip_tab_single: '单注',
|
||||
slip_tab_parlay: '串关',
|
||||
slip_parlay_empty_hint: '先选择一个下注项,再点「加入串关」',
|
||||
slip_add_parlay: '加入串关',
|
||||
slip_parlay_same_match: '串关选项不得为同一场比赛,请先移除同场选项后再加入。',
|
||||
slip_parlay_count: '{n} 项串关',
|
||||
slip_total_stake: '总投注金额',
|
||||
slip_currency: '金额',
|
||||
slip_min: '最低',
|
||||
slip_min_error: '最低投注金额为 {amount}',
|
||||
place_success: '下注成功',
|
||||
place_failed: '下注失败',
|
||||
},
|
||||
@@ -411,7 +409,7 @@ export default {
|
||||
password_disabled: '当前账号不允许自行修改密码,请联系客服',
|
||||
rules_title: '投注规则',
|
||||
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
||||
rules_p2: '串关为 2 串 1 至 5 串 1,每场最多选 1 项;冠军盘、四分盘让球/大小不可进入串关。',
|
||||
rules_p2: '串关为 2 串 1 至 5 串 1,每场比赛最多选择一项;冠军盘、四分盘让球/大小不可进入串关。',
|
||||
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
||||
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
||||
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
|
||||
|
||||
@@ -41,7 +41,14 @@ const showAnnouncement = computed(() => !isDetailPage.value && !route.path.start
|
||||
|
||||
const showBottomNav = computed(() => {
|
||||
const p = route.path;
|
||||
if (p === '/' || p === '/bet' || p === '/bets' || p === '/wallet' || p === '/profile') return true;
|
||||
if (
|
||||
p === '/' ||
|
||||
p === '/bet' ||
|
||||
p.startsWith('/match/') ||
|
||||
p === '/bets' ||
|
||||
p === '/wallet' ||
|
||||
p === '/profile'
|
||||
) return true;
|
||||
return false;
|
||||
});
|
||||
const { announcements, load: loadPlayerHome } = usePlayerHome();
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface SlipItem {
|
||||
oddsVersion: string;
|
||||
matchId: string;
|
||||
matchName: string;
|
||||
marketId?: string;
|
||||
marketName?: string;
|
||||
selectionName: string;
|
||||
odds: number;
|
||||
marketType: string;
|
||||
@@ -19,14 +21,28 @@ export interface SlipItem {
|
||||
allowParlay?: boolean;
|
||||
}
|
||||
|
||||
export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
const items = ref<SlipItem[]>([]);
|
||||
const stake = ref<number>(10);
|
||||
const mode = ref<'single' | 'parlay'>('single');
|
||||
const lastParlayError = ref<ParlayRejectReason | 'MAX_LEGS' | null>(null);
|
||||
export type SlipMode = 'single' | 'parlay';
|
||||
export type ParlaySlipError = ParlayRejectReason | 'MAX_LEGS' | 'SAME_MATCH';
|
||||
|
||||
export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
const singleItem = ref<SlipItem | null>(null);
|
||||
const parlayItems = ref<SlipItem[]>([]);
|
||||
const stake = ref<number>(5);
|
||||
const mode = ref<SlipMode>('single');
|
||||
const lastParlayError = ref<ParlaySlipError | null>(null);
|
||||
|
||||
const items = computed(() =>
|
||||
mode.value === 'parlay'
|
||||
? parlayItems.value
|
||||
: singleItem.value
|
||||
? [singleItem.value]
|
||||
: [],
|
||||
);
|
||||
const count = computed(() => items.value.length);
|
||||
const isParlay = computed(() => mode.value === 'parlay' && items.value.length >= PARLAY_MIN_LEGS);
|
||||
const parlayCount = computed(() => parlayItems.value.length);
|
||||
const singleCount = computed(() => (singleItem.value ? 1 : 0));
|
||||
const totalCount = computed(() => singleCount.value + parlayCount.value);
|
||||
const isParlay = computed(() => mode.value === 'parlay' && parlayItems.value.length >= PARLAY_MIN_LEGS);
|
||||
|
||||
function parseLineValue(v: number | string | null | undefined): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
@@ -34,35 +50,33 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 球赛/详情页:同场可多选,分笔单关;再点同一项可取消 */
|
||||
function addItem(item: SlipItem) {
|
||||
if (mode.value === 'parlay') items.value = [];
|
||||
mode.value = 'single';
|
||||
function setMode(nextMode: SlipMode) {
|
||||
mode.value = nextMode;
|
||||
lastParlayError.value = null;
|
||||
|
||||
const existing = items.value.findIndex(
|
||||
(i) => i.selectionId === item.selectionId,
|
||||
);
|
||||
if (existing >= 0) {
|
||||
items.value.splice(existing, 1);
|
||||
return;
|
||||
}
|
||||
items.value.push(item);
|
||||
}
|
||||
|
||||
/** 串关页:须为不同赛事,每场最多 1 项 */
|
||||
function addParlayLeg(item: SlipItem): ParlayRejectReason | 'MAX_LEGS' | null {
|
||||
if (mode.value === 'single') items.value = [];
|
||||
/** 球赛/详情页:点击任意选项后作为当前单注,打开投注抽屉 */
|
||||
function setSingleItem(item: SlipItem) {
|
||||
mode.value = 'single';
|
||||
singleItem.value = item;
|
||||
lastParlayError.value = null;
|
||||
}
|
||||
|
||||
function addItem(item: SlipItem) {
|
||||
setSingleItem(item);
|
||||
}
|
||||
|
||||
/** 串关:必须来自不同赛事,仍按盘口规则过滤不可串关项 */
|
||||
function addParlayLeg(item: SlipItem): ParlaySlipError | null {
|
||||
mode.value = 'parlay';
|
||||
|
||||
const samePick = items.value.findIndex((i) => i.selectionId === item.selectionId);
|
||||
const samePick = parlayItems.value.findIndex((i) => i.selectionId === item.selectionId);
|
||||
if (samePick >= 0) {
|
||||
items.value.splice(samePick, 1);
|
||||
lastParlayError.value = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (items.value.some((i) => i.matchId === item.matchId)) {
|
||||
if (parlayItems.value.some((i) => i.matchId === item.matchId)) {
|
||||
lastParlayError.value = 'SAME_MATCH';
|
||||
return 'SAME_MATCH';
|
||||
}
|
||||
@@ -77,24 +91,52 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
return check.reason;
|
||||
}
|
||||
|
||||
if (items.value.length >= PARLAY_MAX_LEGS) {
|
||||
if (parlayItems.value.length >= PARLAY_MAX_LEGS) {
|
||||
lastParlayError.value = 'MAX_LEGS';
|
||||
return 'MAX_LEGS';
|
||||
}
|
||||
|
||||
items.value.push(item);
|
||||
parlayItems.value.push(item);
|
||||
lastParlayError.value = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function addSingleToParlay(): ParlaySlipError | null {
|
||||
if (!singleItem.value) return null;
|
||||
return addParlayLeg(singleItem.value);
|
||||
}
|
||||
|
||||
function removeItem(selectionId: string) {
|
||||
items.value = items.value.filter((i) => i.selectionId !== selectionId);
|
||||
if (!items.value.length) mode.value = 'single';
|
||||
if (singleItem.value?.selectionId === selectionId) {
|
||||
singleItem.value = null;
|
||||
}
|
||||
parlayItems.value = parlayItems.value.filter((i) => i.selectionId !== selectionId);
|
||||
if (mode.value === 'parlay' && !parlayItems.value.length) mode.value = 'single';
|
||||
lastParlayError.value = null;
|
||||
}
|
||||
|
||||
function clearSingle() {
|
||||
singleItem.value = null;
|
||||
if (mode.value === 'single') lastParlayError.value = null;
|
||||
}
|
||||
|
||||
function clearParlay() {
|
||||
parlayItems.value = [];
|
||||
mode.value = 'single';
|
||||
lastParlayError.value = null;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
items.value = [];
|
||||
if (mode.value === 'parlay') {
|
||||
clearParlay();
|
||||
return;
|
||||
}
|
||||
clearSingle();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
singleItem.value = null;
|
||||
parlayItems.value = [];
|
||||
mode.value = 'single';
|
||||
lastParlayError.value = null;
|
||||
}
|
||||
@@ -111,22 +153,15 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
return items.value.reduce((acc, i) => acc + stake.value * i.odds, 0);
|
||||
});
|
||||
|
||||
const hasSameMatch = computed(() => {
|
||||
const matchIds = items.value.map((i) => i.matchId);
|
||||
return new Set(matchIds).size !== matchIds.length;
|
||||
});
|
||||
|
||||
const canPlaceParlay = computed(
|
||||
() =>
|
||||
mode.value === 'parlay' &&
|
||||
items.value.length >= PARLAY_MIN_LEGS &&
|
||||
items.value.length <= PARLAY_MAX_LEGS &&
|
||||
!hasSameMatch.value,
|
||||
parlayItems.value.length >= PARLAY_MIN_LEGS &&
|
||||
parlayItems.value.length <= PARLAY_MAX_LEGS,
|
||||
);
|
||||
|
||||
/** 详情页等同场多笔单关(串关模式不走此路径) */
|
||||
const canPlaceBatchSingles = computed(
|
||||
() => mode.value === 'single' && items.value.length >= 1,
|
||||
() => mode.value === 'single' && singleItem.value != null,
|
||||
);
|
||||
|
||||
const canSubmit = computed(
|
||||
@@ -144,23 +179,33 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
singleItem,
|
||||
parlayItems,
|
||||
items,
|
||||
stake,
|
||||
mode,
|
||||
singleCount,
|
||||
parlayCount,
|
||||
totalCount,
|
||||
count,
|
||||
isParlay,
|
||||
totalOdds,
|
||||
potentialReturn,
|
||||
hasSameMatch,
|
||||
canPlaceParlay,
|
||||
canPlaceBatchSingles,
|
||||
canSubmit,
|
||||
lastParlayError,
|
||||
drawerOpen,
|
||||
setMode,
|
||||
setSingleItem,
|
||||
addItem,
|
||||
addParlayLeg,
|
||||
addSingleToParlay,
|
||||
removeItem,
|
||||
clearSingle,
|
||||
clearParlay,
|
||||
clear,
|
||||
clearAll,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
};
|
||||
|
||||
@@ -1,45 +1,7 @@
|
||||
/** 详情页展示顺序(与产品设计稿一致) */
|
||||
export const FEATURED_MARKET_TYPES = [
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
] as const;
|
||||
|
||||
/** 详情页统一列表顺序:波胆在前,其余玩法在后 */
|
||||
export const DETAIL_MARKET_TYPES = [
|
||||
...FEATURED_MARKET_TYPES,
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_1X2',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'HT_1X2',
|
||||
] as const;
|
||||
|
||||
export const GRID_MARKET_TYPES = [
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_1X2',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'HT_1X2',
|
||||
] as const;
|
||||
|
||||
export type CatalogMarketType =
|
||||
| (typeof FEATURED_MARKET_TYPES)[number]
|
||||
| (typeof GRID_MARKET_TYPES)[number];
|
||||
|
||||
export const MARKET_I18N_KEY: Record<string, string> = {
|
||||
FT_CORRECT_SCORE: 'bet.market_cs',
|
||||
HT_CORRECT_SCORE: 'bet.market_ht_cs',
|
||||
SH_CORRECT_SCORE: 'bet.market_sh_cs',
|
||||
FT_HANDICAP: 'bet.market_ft_handicap',
|
||||
FT_OVER_UNDER: 'bet.market_ft_ou',
|
||||
FT_1X2: 'bet.market_ft_1x2',
|
||||
FT_ODD_EVEN: 'bet.market_ft_oe',
|
||||
HT_HANDICAP: 'bet.market_ht_handicap',
|
||||
HT_OVER_UNDER: 'bet.market_ht_ou',
|
||||
HT_1X2: 'bet.market_ht_1x2',
|
||||
};
|
||||
export {
|
||||
DETAIL_MARKET_TYPES,
|
||||
FEATURED_MARKET_TYPES,
|
||||
GRID_MARKET_TYPES,
|
||||
MARKET_I18N_KEY,
|
||||
type FootballMarketType as CatalogMarketType,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
5
apps/player/src/utils/matchDay.ts
Normal file
5
apps/player/src/utils/matchDay.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
isAfterLocalToday as isAfterTodayMatchWindow,
|
||||
isInLocalToday as isInTodayMatchWindow,
|
||||
isSameLocalCalendarDay as isSameCalendarDay,
|
||||
} from '@thebet365/shared';
|
||||
@@ -1,44 +0,0 @@
|
||||
/** 串关列表表头与玩法类型(labelKey 对应 main.ts bet.*) */
|
||||
export const PARLAY_MARKET_TYPES = [
|
||||
{ key: 'FT_HANDICAP', labelKey: 'market_ft_handicap' },
|
||||
{ key: 'FT_OVER_UNDER', labelKey: 'market_ft_ou' },
|
||||
{ key: 'FT_1X2', labelKey: 'market_ft_1x2' },
|
||||
{ key: 'FT_ODD_EVEN', labelKey: 'market_ft_oe' },
|
||||
{ key: 'HT_HANDICAP', labelKey: 'market_ht_handicap' },
|
||||
{ key: 'HT_OVER_UNDER', labelKey: 'market_ht_ou' },
|
||||
{ key: 'HT_1X2', labelKey: 'market_ht_1x2' },
|
||||
] as const;
|
||||
|
||||
export type ParlayMarketType = (typeof PARLAY_MARKET_TYPES)[number]['key'];
|
||||
|
||||
/** 按全场 / 半场分组(headerKey 对应 bet.ft / bet.ht) */
|
||||
export const PARLAY_MARKET_GROUPS = [
|
||||
{
|
||||
headerKey: 'ft',
|
||||
columns: [
|
||||
{ key: 'FT_HANDICAP', labelKey: 'parlay_lbl_handicap' },
|
||||
{ key: 'FT_OVER_UNDER', labelKey: 'parlay_lbl_ou' },
|
||||
{ key: 'FT_1X2', labelKey: 'parlay_lbl_1x2' },
|
||||
{ key: 'FT_ODD_EVEN', labelKey: 'parlay_lbl_oe' },
|
||||
],
|
||||
},
|
||||
{
|
||||
headerKey: 'ht',
|
||||
columns: [
|
||||
{ key: 'HT_HANDICAP', labelKey: 'parlay_lbl_handicap' },
|
||||
{ key: 'HT_OVER_UNDER', labelKey: 'parlay_lbl_ou' },
|
||||
{ key: 'HT_1X2', labelKey: 'parlay_lbl_1x2' },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** 选项简称 i18n key(串关格内展示) */
|
||||
export const PARLAY_SELECTION_KEYS: Record<string, string> = {
|
||||
HOME: 'parlay_sel_home',
|
||||
AWAY: 'parlay_sel_away',
|
||||
DRAW: 'parlay_sel_draw',
|
||||
OVER: 'parlay_sel_over',
|
||||
UNDER: 'parlay_sel_under',
|
||||
ODD: 'parlay_sel_odd',
|
||||
EVEN: 'parlay_sel_even',
|
||||
};
|
||||
@@ -15,14 +15,41 @@ export function resolveSelectionLabel(
|
||||
t: (key: string) => string,
|
||||
code: string,
|
||||
fallback: string,
|
||||
opts?: { lineValue?: string | number | null },
|
||||
): string {
|
||||
const i18nKey = CODE_I18N[code];
|
||||
if (i18nKey) {
|
||||
const fullKey = `bet.${i18nKey}`;
|
||||
const v = t(fullKey);
|
||||
if (v !== fullKey) return v;
|
||||
if (v !== fullKey) return withLineValue(code, v, opts?.lineValue);
|
||||
}
|
||||
const parsed = parseScoreCode(code, t);
|
||||
if (parsed) return parsed.display;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseLineValue(value: string | number | null | undefined) {
|
||||
if (value == null || value === '') return null;
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function formatLineValue(value: number) {
|
||||
const n = Object.is(value, -0) ? 0 : value;
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
return String(n).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function formatSignedLineValue(value: number) {
|
||||
const n = Object.is(value, -0) ? 0 : value;
|
||||
return n > 0 ? `+${formatLineValue(n)}` : formatLineValue(n);
|
||||
}
|
||||
|
||||
function withLineValue(code: string, label: string, value: string | number | null | undefined) {
|
||||
const line = parseLineValue(value);
|
||||
if (line == null) return label;
|
||||
if (code === 'HOME') return `${label} ${formatSignedLineValue(line)}`;
|
||||
if (code === 'AWAY') return `${label} ${formatSignedLineValue(-line)}`;
|
||||
if (code === 'OVER' || code === 'UNDER') return `${label} ${formatLineValue(line)}`;
|
||||
return label;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed, onActivated, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import { usePlayerMatches } from '../composables/usePlayerMatches';
|
||||
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
|
||||
import OutrightPanel from '../components/outright/OutrightPanel.vue';
|
||||
import ParlayPanel from '../components/parlay/ParlayPanel.vue';
|
||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import type { MatchPhase } from '../utils/matchPhase';
|
||||
import {
|
||||
isAfterLocalToday as isAfterTodayMatchWindow,
|
||||
isInLocalToday as isInTodayMatchWindow,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
type MainTab = 'matches' | 'outright' | 'parlay';
|
||||
type MainTab = 'matches' | 'outright';
|
||||
type TimeTab = 'today' | 'early';
|
||||
|
||||
interface Match {
|
||||
@@ -34,10 +36,18 @@ interface Match {
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: MatchPhase;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -50,17 +60,18 @@ interface LeagueGroup {
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const slip = useBetSlipStore();
|
||||
|
||||
const mainTab = ref<MainTab>('matches');
|
||||
const timeTab = ref<TimeTab>('early');
|
||||
const timeTab = ref<TimeTab>('today');
|
||||
const showAll = ref(false);
|
||||
const filterNow = ref(new Date());
|
||||
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
|
||||
const matches = summaryMatches;
|
||||
const loading = summaryLoading;
|
||||
const expandedLeagues = ref<Set<string>>(new Set());
|
||||
|
||||
async function loadMatches() {
|
||||
filterNow.value = new Date();
|
||||
await loadSummary(true);
|
||||
}
|
||||
|
||||
@@ -75,26 +86,14 @@ const pullIndicatorStyle = () => ({
|
||||
opacity: Math.min(pullDistance.value / 48, 1),
|
||||
});
|
||||
|
||||
function dayStart(d: Date) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function isKickoffToday(startTime: string) {
|
||||
const kick = new Date(startTime);
|
||||
const now = new Date();
|
||||
const start = dayStart(now);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return kick >= start && kick < end;
|
||||
}
|
||||
|
||||
const filteredMatches = computed(() => {
|
||||
if (mainTab.value !== 'matches') return [];
|
||||
const now = filterNow.value;
|
||||
return matches.value.filter((m) => {
|
||||
const today = isKickoffToday(m.startTime);
|
||||
const timeMatch = timeTab.value === 'today' ? today : !today;
|
||||
const timeMatch =
|
||||
timeTab.value === 'today'
|
||||
? isInTodayMatchWindow(m.startTime, now)
|
||||
: isAfterTodayMatchWindow(m.startTime, now);
|
||||
if (!timeMatch) return false;
|
||||
if (!showAll.value && m.matchPhase !== 'open' && m.matchPhase !== undefined) return false;
|
||||
return true;
|
||||
@@ -157,6 +156,11 @@ function selectMainTab(tab: MainTab) {
|
||||
mainTab.value = tab;
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
filterNow.value = new Date();
|
||||
timeTab.value = 'today';
|
||||
});
|
||||
|
||||
function goMatch(id: string) {
|
||||
router.push(`/match/${id}`);
|
||||
}
|
||||
@@ -190,16 +194,6 @@ function goMatch(id: string) {
|
||||
<span class="tab-icon">🏆</span>
|
||||
{{ t('bet.tab_outright') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="main-tab parlay-tab"
|
||||
:class="{ active: mainTab === 'parlay', 'tab-gold-active': mainTab === 'parlay' }"
|
||||
@click="selectMainTab('parlay')"
|
||||
>
|
||||
<span class="tab-icon">+</span>
|
||||
{{ t('bet.tab_parlay') }}
|
||||
<span v-if="slip.count" class="tab-badge">{{ slip.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="mainTab === 'matches'">
|
||||
@@ -226,11 +220,12 @@ function goMatch(id: string) {
|
||||
<button
|
||||
type="button"
|
||||
class="phase-toggle"
|
||||
:class="{ 'phase-toggle--active': showAll }"
|
||||
:class="{ 'phase-toggle--active': !showAll }"
|
||||
:aria-pressed="!showAll"
|
||||
@click="showAll = !showAll"
|
||||
>
|
||||
<span class="phase-toggle-dot" />
|
||||
{{ showAll ? t('bet.show_all_matches') : t('bet.show_open_only') }}
|
||||
{{ t('bet.show_open_only') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -259,7 +254,6 @@ function goMatch(id: string) {
|
||||
|
||||
<OutrightPanel v-if="mainTab === 'outright'" class="outright-tab" />
|
||||
|
||||
<ParlayPanel v-if="mainTab === 'parlay'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -308,20 +302,6 @@ function goMatch(id: string) {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: #1a1000;
|
||||
color: var(--primary-light);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.time-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -340,7 +320,14 @@ function goMatch(id: string) {
|
||||
}
|
||||
|
||||
.time-tab.active {
|
||||
background: var(--gradient-gold) !important;
|
||||
border-color: #fff1a8 !important;
|
||||
color: #2a1a00 !important;
|
||||
font-weight: 800;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 244, 200, 0.28) inset,
|
||||
0 4px 16px rgba(212, 175, 55, 0.28);
|
||||
text-shadow: 0 1px 0 rgba(255, 252, 235, 0.75);
|
||||
}
|
||||
|
||||
.phase-filter {
|
||||
@@ -403,10 +390,6 @@ function goMatch(id: string) {
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.parlay-tab.tab-gold-active {
|
||||
flex: 1.15;
|
||||
}
|
||||
|
||||
.outright-tab {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
const { t, locale } = useI18n();
|
||||
@@ -29,14 +30,7 @@ function goMatch(id: string) {
|
||||
}
|
||||
|
||||
function formatKickoff(startTime: string) {
|
||||
return new Date(startTime).toLocaleString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
return formatLocalMatchDateTime(startTime, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -7,21 +7,17 @@ import { formatMoney } from '../utils/localeDisplay';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||
import { DETAIL_MARKET_TYPES, MARKET_I18N_KEY } from '../utils/marketCatalog';
|
||||
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 CorrectScoreConfirmModal, {
|
||||
type CsConfirmLine,
|
||||
} from '../components/match-detail/CorrectScoreConfirmModal.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 BetSuccessOverlay from '../components/BetSuccessOverlay.vue';
|
||||
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -36,8 +32,11 @@ function goLogin() {
|
||||
interface Market {
|
||||
id: string;
|
||||
marketType: string;
|
||||
marketDisplayName?: string;
|
||||
lineKey?: string | null;
|
||||
period: string;
|
||||
lineValue?: string | number | null;
|
||||
allowSingle?: boolean;
|
||||
allowParlay?: boolean;
|
||||
promoLabel?: string | null;
|
||||
selections: Selection[];
|
||||
@@ -47,6 +46,7 @@ interface Selection {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
selectionDisplayName?: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}
|
||||
@@ -67,25 +67,25 @@ interface MatchDetail {
|
||||
status?: string;
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: MatchPhase;
|
||||
correctScoreEnabled?: boolean;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
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);
|
||||
const expandedKey = ref<string | null>(null);
|
||||
const correctScoreStakes = ref<Record<string, number>>({});
|
||||
const placingCs = ref(false);
|
||||
const csMessage = ref('');
|
||||
const showCsSuccess = ref(false);
|
||||
const csConfirmOpen = ref(false);
|
||||
const csConfirmMarketType = ref<string | null>(null);
|
||||
|
||||
interface MyBet {
|
||||
betNo: string;
|
||||
@@ -131,37 +131,19 @@ const statusClass = (status: string) => {
|
||||
if (s === 'LOST' || s === 'LOSE') return 'bet-status-lost';
|
||||
return 'bet-status-pending';
|
||||
};
|
||||
const marketsByType = computed(() => {
|
||||
const marketsById = computed(() => {
|
||||
const map = new Map<string, Market>();
|
||||
for (const m of match.value?.markets ?? []) {
|
||||
map.set(m.marketType, m);
|
||||
map.set(m.id, m);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const CS_MARKET_TYPES = new Set(['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE']);
|
||||
|
||||
const visibleMarketTypes = computed(() => {
|
||||
const csEnabled = match.value?.correctScoreEnabled ?? true;
|
||||
if (csEnabled) return DETAIL_MARKET_TYPES;
|
||||
return DETAIL_MARKET_TYPES.filter((t) => !CS_MARKET_TYPES.has(t));
|
||||
});
|
||||
|
||||
function marketPromoLabel(marketType: string) {
|
||||
const m = marketsByType.value.get(marketType);
|
||||
return m?.promoLabel?.trim() || '';
|
||||
}
|
||||
const visibleMarkets = computed(() => match.value?.markets ?? []);
|
||||
|
||||
const kickoff = computed(() => {
|
||||
if (!match.value) return '';
|
||||
return new Date(match.value.startTime).toLocaleString(locale.value, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
return formatLocalMatchDateTime(match.value.startTime, locale.value, { variant: 'full' });
|
||||
});
|
||||
|
||||
const bettingOpen = computed(() => match.value?.bettingOpen !== false);
|
||||
@@ -175,119 +157,44 @@ const phaseLabel = computed(() => matchPhaseLabel(t, matchPhase.value));
|
||||
|
||||
const liveScoreText = computed(() => {
|
||||
const s = match.value?.score;
|
||||
if (!s) return '';
|
||||
if (!s || s.ftHome == null || s.ftAway == null) return '';
|
||||
return `${s.ftHome} - ${s.ftAway}`;
|
||||
});
|
||||
|
||||
function marketLabel(marketType: string) {
|
||||
const key = MARKET_I18N_KEY[marketType];
|
||||
return key ? t(key) : marketType;
|
||||
function statValue(value: number | null | undefined) {
|
||||
return value == null ? '-' : String(value);
|
||||
}
|
||||
|
||||
function expandKey(marketType: string) {
|
||||
return marketType;
|
||||
function statPair(home: number | null | undefined, away: number | null | undefined) {
|
||||
return `${statValue(home)} - ${statValue(away)}`;
|
||||
}
|
||||
|
||||
function isExpanded(marketType: string) {
|
||||
return expandedKey.value === expandKey(marketType);
|
||||
}
|
||||
|
||||
function openMarket(marketType: string) {
|
||||
if (!marketsByType.value.has(marketType)) return;
|
||||
expandedKey.value = expandKey(marketType);
|
||||
}
|
||||
|
||||
function closeMarket() {
|
||||
expandedKey.value = null;
|
||||
}
|
||||
|
||||
function toggleMarket(marketType: string) {
|
||||
if (!marketsByType.value.has(marketType)) return;
|
||||
if (isExpanded(marketType)) closeMarket();
|
||||
else openMarket(marketType);
|
||||
}
|
||||
|
||||
function genRequestId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
const csConfirmLines = computed((): CsConfirmLine[] => {
|
||||
const marketType = csConfirmMarketType.value;
|
||||
if (!marketType) return [];
|
||||
const market = marketsByType.value.get(marketType);
|
||||
if (!market) return [];
|
||||
return market.selections
|
||||
.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0)
|
||||
.map((s) => {
|
||||
const parsed = parseScoreCode(s.selectionCode, t);
|
||||
return {
|
||||
scoreDisplay: parsed?.display ?? s.selectionName,
|
||||
odds: s.odds,
|
||||
stake: correctScoreStakes.value[s.id],
|
||||
};
|
||||
});
|
||||
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) },
|
||||
];
|
||||
});
|
||||
|
||||
function openCorrectScoreConfirm(marketType: string) {
|
||||
const market = marketsByType.value.get(marketType);
|
||||
if (!market || !match.value) return;
|
||||
const hasStake = market.selections.some((s) => (correctScoreStakes.value[s.id] ?? 0) > 0);
|
||||
if (!hasStake) {
|
||||
csMessage.value = t('bet.cs_stake_required');
|
||||
return;
|
||||
}
|
||||
csMessage.value = '';
|
||||
csConfirmMarketType.value = marketType;
|
||||
csConfirmOpen.value = true;
|
||||
const showResultStats = computed(() => resultStats.value.length > 0);
|
||||
|
||||
function marketLabel(market: Market | null | undefined) {
|
||||
return market?.marketDisplayName?.trim() || market?.marketType || '';
|
||||
}
|
||||
|
||||
function closeCorrectScoreConfirm() {
|
||||
csConfirmOpen.value = false;
|
||||
}
|
||||
|
||||
async function confirmCorrectScoreBets() {
|
||||
const marketType = csConfirmMarketType.value;
|
||||
if (!marketType) return;
|
||||
csConfirmOpen.value = false;
|
||||
await placeCorrectScoreBets(marketType);
|
||||
}
|
||||
|
||||
async function placeCorrectScoreBets(marketType: string) {
|
||||
if (!bettingOpen.value) return;
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
const market = marketsByType.value.get(marketType);
|
||||
if (!market || !match.value) return;
|
||||
const entries = market.selections.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0);
|
||||
if (!entries.length) {
|
||||
csMessage.value = t('bet.cs_stake_required');
|
||||
return;
|
||||
}
|
||||
placingCs.value = true;
|
||||
csMessage.value = '';
|
||||
try {
|
||||
for (const sel of entries) {
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: sel.id,
|
||||
oddsVersion: String(sel.oddsVersion),
|
||||
stake: correctScoreStakes.value[sel.id],
|
||||
requestId: genRequestId(),
|
||||
});
|
||||
}
|
||||
csMessage.value = t('bet.cs_place_success');
|
||||
const next = { ...correctScoreStakes.value };
|
||||
for (const sel of entries) delete next[sel.id];
|
||||
correctScoreStakes.value = next;
|
||||
showCsSuccess.value = true;
|
||||
} catch (e: unknown) {
|
||||
csMessage.value =
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||
t('bet.cs_place_failed');
|
||||
} finally {
|
||||
placingCs.value = false;
|
||||
}
|
||||
function selectionLabel(sel: Selection) {
|
||||
const parsedScore = parseScoreCode(sel.selectionCode, t);
|
||||
if (parsedScore) return parsedScore.display;
|
||||
return sel.selectionDisplayName?.trim() || sel.selectionName;
|
||||
}
|
||||
|
||||
async function loadMatch() {
|
||||
@@ -313,17 +220,24 @@ const pullIndicatorStyle = () => ({
|
||||
});
|
||||
|
||||
function isSelected(id: string) {
|
||||
return slip.items.some((i) => i.selectionId === id);
|
||||
return slip.singleItem?.selectionId === id || slip.parlayItems.some((i) => i.selectionId === id);
|
||||
}
|
||||
|
||||
function toggleSelection(sel: Selection, market: Market) {
|
||||
if (!match.value || !bettingOpen.value) return;
|
||||
if (market.allowSingle === false) 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}`,
|
||||
selectionName: sel.selectionName,
|
||||
marketId: market.id,
|
||||
marketName: marketLabel(market),
|
||||
selectionName: selectionLabel(sel),
|
||||
odds: parseFloat(sel.odds),
|
||||
marketType: market.marketType,
|
||||
lineValue:
|
||||
@@ -332,29 +246,14 @@ function toggleSelection(sel: Selection, market: Market) {
|
||||
: null,
|
||||
allowParlay: market.allowParlay,
|
||||
});
|
||||
}
|
||||
|
||||
function onPickSelection(selId: string, marketType: string) {
|
||||
const market = marketsByType.value.get(marketType);
|
||||
const sel = market?.selections.find((s) => s.id === selId);
|
||||
if (!market || !sel) return;
|
||||
toggleSelection(sel, market);
|
||||
}
|
||||
|
||||
function openBetSlipDrawer() {
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
slip.openDrawer();
|
||||
}
|
||||
|
||||
/** 当前玩法是否已有选项加入投注单 */
|
||||
function hasSlipPickForMarket(marketType: string) {
|
||||
if (!match.value || slip.mode !== 'single') return false;
|
||||
return slip.items.some(
|
||||
(item) => item.matchId === match.value!.id && item.marketType === marketType,
|
||||
);
|
||||
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>
|
||||
|
||||
@@ -446,6 +345,19 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
<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>
|
||||
@@ -466,77 +378,45 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
</section>
|
||||
|
||||
<section class="markets-section">
|
||||
<CorrectScoreConfirmModal
|
||||
:open="csConfirmOpen"
|
||||
:market-label="csConfirmMarketType ? marketLabel(csConfirmMarketType) : ''"
|
||||
:home-team-name="match.homeTeamName"
|
||||
:away-team-name="match.awayTeamName"
|
||||
:lines="csConfirmLines"
|
||||
:loading="placingCs"
|
||||
@close="closeCorrectScoreConfirm"
|
||||
@confirm="confirmCorrectScoreBets"
|
||||
/>
|
||||
|
||||
<p v-if="csMessage" class="cs-toast">{{ csMessage }}</p>
|
||||
|
||||
<div class="market-list">
|
||||
<div
|
||||
v-for="marketType in visibleMarketTypes"
|
||||
:key="marketType"
|
||||
v-for="market in visibleMarkets"
|
||||
:key="market.id"
|
||||
class="market-group"
|
||||
:class="{ open: isExpanded(marketType) }"
|
||||
:class="{ open: true }"
|
||||
>
|
||||
<MarketTypeTile
|
||||
:label="marketLabel(marketType)"
|
||||
:promo-label="marketPromoLabel(marketType)"
|
||||
:has-market="marketsByType.has(marketType)"
|
||||
:expanded="isExpanded(marketType)"
|
||||
@toggle="toggleMarket(marketType)"
|
||||
:label="marketLabel(market)"
|
||||
:promo-label="market.promoLabel?.trim() || ''"
|
||||
:has-market="true"
|
||||
:expanded="true"
|
||||
/>
|
||||
<template v-if="isExpanded(marketType) && marketsByType.get(marketType)">
|
||||
<div class="market-panel-wrap" :class="{ locked: !bettingOpen }">
|
||||
<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>
|
||||
<div class="market-panel-wrap" :class="{ locked: !bettingOpen || market.allowSingle === false }">
|
||||
<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>
|
||||
<CorrectScorePanel
|
||||
v-if="isCorrectScoreMarket(marketType)"
|
||||
:market-type="marketType"
|
||||
:selections="marketsByType.get(marketType)!.selections"
|
||||
:locked="!bettingOpen"
|
||||
v-model:stakes="correctScoreStakes"
|
||||
v-if="isCorrectScoreMarket(market.marketType)"
|
||||
:market-type="market.marketType"
|
||||
:selections="market.selections"
|
||||
:locked="!bettingOpen || market.allowSingle === false"
|
||||
:is-selected="isSelected"
|
||||
@pick="onPickSelection($event, market.id)"
|
||||
/>
|
||||
<button
|
||||
v-if="isCorrectScoreMarket(marketType) && bettingOpen"
|
||||
type="button"
|
||||
class="market-foot-btn"
|
||||
@click="openCorrectScoreConfirm(marketType)"
|
||||
>
|
||||
{{ t('bet.cs_confirm_cell') }}
|
||||
</button>
|
||||
<MarketSelectionsPanel
|
||||
v-else
|
||||
compact
|
||||
:locked="!bettingOpen"
|
||||
:selections="marketsByType.get(marketType)!.selections"
|
||||
:locked="!bettingOpen || market.allowSingle === false"
|
||||
:line-value="market.lineValue"
|
||||
:selections="market.selections"
|
||||
:is-selected="isSelected"
|
||||
@pick="onPickSelection($event, marketType)"
|
||||
@pick="onPickSelection($event, market.id)"
|
||||
/>
|
||||
<button
|
||||
v-if="!isCorrectScoreMarket(marketType) && bettingOpen && hasSlipPickForMarket(marketType)"
|
||||
type="button"
|
||||
class="market-foot-btn"
|
||||
@click="openBetSlipDrawer"
|
||||
>
|
||||
{{ t('bet.cs_confirm_cell') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<BetSuccessOverlay :show="showCsSuccess" @done="showCsSuccess = false" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -704,6 +584,76 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user