This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View File

@@ -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;
});

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}[];
}>();

View File

@@ -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);
}

View File

@@ -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),
);

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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">&#9917;</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>

View File

@@ -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,
};
}

View File

@@ -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 25 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 25 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 (25 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 25 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: 25 legs from different matches (one per match). Outright and quarter-ball HDP/O-U are excluded.',
rules_p2: 'Parlays: 25 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.',

View File

@@ -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 25 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 25 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 (25 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 25 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',
},

View File

@@ -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: '选择 25 场赛前赛事组合串关2 串 1 至 5 串 1。赔率相乘不含滚球、冠军盘与四分盘让球/大小。',
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
parlay_guide_2: '须选 25 项,且须为不同赛事;冠军盘与四分盘让球/大小不可选',
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: '串关25 ',
guide_parlay_1: '本页为单关/波胆。串关:底部导航点「投注」,在页面顶部切换到「串关投注」,选 25 场不同赛事后在投注单提交。',
guide_cs_1: '在比分表中点击要下注的比分',
guide_cs_2: '系统会打开投注抽屉,填写金额后提交',
guide_cs_3: '比分项按单注下注,也可以加入串关',
guide_flow_parlay: '串关25 ',
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: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',

View File

@@ -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();

View File

@@ -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,
};

View File

@@ -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';

View File

@@ -0,0 +1,5 @@
export {
isAfterLocalToday as isAfterTodayMatchWindow,
isInLocalToday as isInTodayMatchWindow,
isSameLocalCalendarDay as isSameCalendarDay,
} from '@thebet365/shared';

View File

@@ -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',
};

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;