完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
487 lines
11 KiB
Vue
487 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import api from '../../api';
|
|
import { useBetSlipStore } from '../../stores/betSlip';
|
|
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
|
|
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS } from '../../utils/parlayColumns';
|
|
import BetGuideHelp from '../BetGuideHelp.vue';
|
|
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[];
|
|
}
|
|
|
|
interface ParlayMatch {
|
|
id: string;
|
|
leagueName: string;
|
|
homeTeamName: string;
|
|
awayTeamName: string;
|
|
startTime: string;
|
|
markets: Market[];
|
|
}
|
|
|
|
const { t, locale } = useI18n();
|
|
const slip = useBetSlipStore();
|
|
|
|
const loading = ref(true);
|
|
const matches = ref<ParlayMatch[]>([]);
|
|
const timeFilter = ref<TimeFilter>('all');
|
|
|
|
const parlayMarketKeys = PARLAY_MARKET_TYPES.map((c) => c.key);
|
|
|
|
async function loadParlayMatches() {
|
|
loading.value = true;
|
|
try {
|
|
const { data } = await api.get('/player/matches');
|
|
matches.value = (data.data ?? []).filter(
|
|
(m: ParlayMatch) => m.markets?.length && hasParlayMarkets(m),
|
|
);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
useOnLocaleChange(loadParlayMatches);
|
|
|
|
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(() => {
|
|
if (timeFilter.value === 'all') return matches.value;
|
|
return matches.value.filter((m) => isKickoffToday(m.startTime));
|
|
});
|
|
|
|
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) {
|
|
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 parlayHint.value = '';
|
|
}
|
|
|
|
function openSlip() {
|
|
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'),
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="parlay-panel" :class="{ 'has-fixed-foot': showParlayFoot }">
|
|
<div class="toolbar">
|
|
<div class="toolbar-filters">
|
|
<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>
|
|
<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 class="col-headers">
|
|
<span v-for="col in PARLAY_MARKET_TYPES" :key="col.key" class="col-head">{{ colLabel(col.labelKey) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
|
|
|
|
<div v-else-if="filteredMatches.length" class="table-wrap">
|
|
<div v-for="match in filteredMatches" :key="match.id" class="match-row">
|
|
<div class="match-info">
|
|
<div class="league">{{ match.leagueName }}</div>
|
|
<div class="teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
|
|
<div class="time">{{ formatKickoff(match.startTime) }}</div>
|
|
</div>
|
|
<div class="odds-cells">
|
|
<div
|
|
v-for="col in PARLAY_MARKET_TYPES"
|
|
:key="col.key"
|
|
class="market-cell"
|
|
>
|
|
<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) }"
|
|
@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 v-else class="empty">
|
|
<span class="empty-icon" aria-hidden="true">📅</span>
|
|
<p>{{ t('bet.parlay_empty') }}</p>
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<div v-if="showParlayFoot" class="parlay-foot-fixed">
|
|
<p v-if="parlayHint" class="foot-hint foot-hint--warn">{{ parlayHint }}</p>
|
|
<p v-else-if="slip.count < 2" class="foot-hint">{{ t('bet.parlay_need_more') }}</p>
|
|
<p v-else-if="slip.count > PARLAY_MAX_LEGS" class="foot-hint">{{ t('bet.parlay_max_legs') }}</p>
|
|
<p v-else class="foot-meta">
|
|
{{ t('bet.bet_slip') }} ({{ slip.count }}) · {{ t('bet.parlay') }}
|
|
{{ slip.totalOdds.toFixed(2) }}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
class="market-foot-btn"
|
|
:disabled="!slip.canPlaceParlay"
|
|
@click="openSlip"
|
|
>
|
|
{{ footConfirmLabel }}
|
|
</button>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.parlay-panel {
|
|
padding: 0 12px 16px;
|
|
}
|
|
|
|
.parlay-panel.has-fixed-foot {
|
|
padding-bottom: 100px;
|
|
}
|
|
|
|
.toolbar-filters {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.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-hint--info {
|
|
color: var(--primary-light);
|
|
text-align: center;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.filter-select {
|
|
flex-shrink: 0;
|
|
min-width: 72px;
|
|
padding: 8px 10px;
|
|
border-radius: 6px;
|
|
background: #141414;
|
|
border: 1px solid var(--border-gold-soft);
|
|
color: var(--primary-light);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.col-headers {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, minmax(52px, 1fr));
|
|
gap: 4px;
|
|
flex: 1;
|
|
min-width: 360px;
|
|
align-items: center;
|
|
}
|
|
|
|
.col-head {
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
color: var(--text-muted);
|
|
text-align: center;
|
|
line-height: 1.25;
|
|
word-break: keep-all;
|
|
}
|
|
|
|
.table-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.match-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 10px 8px;
|
|
background: #141414;
|
|
border: 1px solid #2a2a2a;
|
|
border-radius: 6px;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.match-info {
|
|
flex-shrink: 0;
|
|
width: 88px;
|
|
min-width: 88px;
|
|
}
|
|
|
|
.league {
|
|
font-size: 9px;
|
|
color: var(--text-muted);
|
|
margin-bottom: 2px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.teams {
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
color: var(--primary-light);
|
|
line-height: 1.25;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.time {
|
|
font-size: 9px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.odds-cells {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, minmax(52px, 1fr));
|
|
gap: 4px;
|
|
flex: 1;
|
|
min-width: 360px;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.market-cell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
min-height: 36px;
|
|
}
|
|
|
|
.odd-btn {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 1px;
|
|
padding: 4px 2px;
|
|
border-radius: 4px;
|
|
background: #0d0d0d;
|
|
border: 1px solid #333;
|
|
min-height: 32px;
|
|
width: 100%;
|
|
}
|
|
|
|
.odd-btn.picked {
|
|
border-color: var(--primary);
|
|
background: rgba(212, 175, 55, 0.15);
|
|
}
|
|
|
|
.odd-label {
|
|
font-size: 9px;
|
|
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 {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #444;
|
|
font-size: 12px;
|
|
min-height: 32px;
|
|
}
|
|
|
|
.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>
|