Files
thebet365/apps/player/src/components/outright/OutrightBetModal.vue
Mars 8a1ba0fd95 feat(player): 新增桌面端 UI 与响应式双端路由架构
- 将原移动端页面重命名为 Mobile*,新增 22 个 Desktop* 视图与 DesktopShell 布局体系
- 路由入口改为 Wrapper 视图,通过 useViewport(≥1024px)自动切换移动/桌面端
- 新增投注单面板、盘口弹层、联赛侧栏、数据表格等桌面组件及独立样式令牌
- 扩展 betSlip store、串关筛选、冠军盘、Toast/悬浮信箱等交互与三语 i18n
- 公告/消息 Hub 拆分移动与桌面实现;API 投注/钱包/充值记录支持 pageSize 分页
2026-06-29 18:01:39 +08:00

717 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import { usePlayerProfile } from '../../composables/usePlayerProfile';
import { formatMoney, parseAmount } from '../../utils/localeDisplay';
import { teamFlagUrl } from '../../utils/teamFlag';
import BetSuccessOverlay from '../BetSuccessOverlay.vue';
import ConfirmDialog from '../ConfirmDialog.vue';
import { buildBetPlaceConfirmMessage } from '../../utils/betPlaceConfirmMessage';
export interface OutrightPick {
selectionId: string;
oddsVersion: string;
teamCode: string;
teamName: string;
odds: string;
eventTitle: string;
}
interface SelectionOddsRow {
id: string;
odds: string;
oddsVersion: string;
status: string;
marketStatus: string;
marketShowOnPlayer: boolean;
matchStatus: string;
}
type OddsDelta = {
oldOdds: number;
newOdds: number;
newVersion: string;
suspended: boolean;
};
const props = defineProps<{
open: boolean;
pick: OutrightPick | null;
}>();
const emit = defineEmits<{ close: [] }>();
const { t, locale } = useI18n();
const { refreshProfile } = usePlayerProfile();
const step = ref<'form' | 'success'>('form');
const stake = ref(1);
const loading = ref(false);
const error = ref('');
const balance = ref(0);
const successBalance = ref(0);
const successStake = ref(0);
const showSuccess = ref(false);
const showPlaceConfirm = ref(false);
const placeConfirmMessage = ref('');
const currentOdds = ref('');
const currentOddsVersion = ref('');
const oddsDelta = ref<OddsDelta | null>(null);
const ODDS_POLL_MS = 5000;
let oddsPollTimer: ReturnType<typeof setInterval> | null = null;
const flagUrl = computed(() =>
props.pick ? teamFlagUrl(props.pick.teamCode, props.pick.teamName) : null,
);
const balanceText = computed(() => formatMoney(balance.value, locale.value));
const effectiveOdds = computed(() => {
if (oddsDelta.value && !oddsDelta.value.suspended) return oddsDelta.value.newOdds;
const n = parseFloat(currentOdds.value);
return Number.isFinite(n) ? n : 0;
});
const oddsNum = computed(() => effectiveOdds.value);
const estReturn = computed(() => {
const s = Number(stake.value);
if (!s || s <= 0 || !oddsNum.value) return 0;
return s * oddsNum.value;
});
const estReturnText = computed(() => formatMoney(estReturn.value, locale.value));
const hasPendingOddsChanges = computed(() => Boolean(oddsDelta.value && !oddsDelta.value.suspended));
const hasSuspendedSelection = computed(() => Boolean(oddsDelta.value?.suspended));
const oddsWarningText = computed(() => {
if (hasSuspendedSelection.value) return t('bet.odds_suspended');
if (hasPendingOddsChanges.value) return t('bet.odds_changed');
return '';
});
const submitButtonLabel = computed(() => {
if (loading.value) return t('bet.placing');
if (hasPendingOddsChanges.value) return t('bet.accept_changes_place');
return t('bet.place_bet_short');
});
function syncPickState() {
if (!props.pick) return;
currentOdds.value = props.pick.odds;
currentOddsVersion.value = props.pick.oddsVersion;
oddsDelta.value = null;
}
function acceptPendingOdds() {
if (!oddsDelta.value || oddsDelta.value.suspended) return;
currentOdds.value = oddsDelta.value.newOdds.toFixed(2);
currentOddsVersion.value = oddsDelta.value.newVersion;
oddsDelta.value = null;
}
function stopOddsPolling() {
if (oddsPollTimer) {
clearInterval(oddsPollTimer);
oddsPollTimer = null;
}
}
async function pollSelectionOdds() {
if (!props.pick || !props.open || step.value !== 'form') return;
try {
const { data } = await api.get('/player/selections/odds', {
params: { ids: props.pick.selectionId },
});
const row: SelectionOddsRow | undefined = data.data?.items?.[0];
if (!row) return;
const suspended =
row.status !== 'OPEN' ||
row.marketStatus !== 'OPEN' ||
row.marketShowOnPlayer === false ||
row.matchStatus !== 'PUBLISHED';
const newOdds = parseFloat(row.odds);
const versionChanged = row.oddsVersion !== currentOddsVersion.value;
const baseOdds = parseFloat(currentOdds.value);
const oddsChanged = Number.isFinite(newOdds) && Math.abs(newOdds - baseOdds) > 0.0001;
if (suspended || versionChanged || oddsChanged) {
oddsDelta.value = {
oldOdds: oddsDelta.value?.oldOdds ?? baseOdds,
newOdds: Number.isFinite(newOdds) ? newOdds : baseOdds,
newVersion: row.oddsVersion,
suspended,
};
} else {
oddsDelta.value = null;
}
} catch {
/* silent retry on next tick */
}
}
function startOddsPolling() {
stopOddsPolling();
void pollSelectionOdds();
oddsPollTimer = setInterval(() => {
void pollSelectionOdds();
}, ODDS_POLL_MS);
}
watch(
() => props.open,
async (v) => {
if (!v) {
stopOddsPolling();
oddsDelta.value = null;
return;
}
step.value = 'form';
stake.value = 1;
error.value = '';
syncPickState();
try {
const { data } = await api.get('/player/profile');
balance.value = parseAmount(data.data?.wallet?.availableBalance);
} catch {
balance.value = 0;
}
startOddsPolling();
},
);
watch(
() => props.pick?.selectionId,
() => {
if (!props.open) return;
syncPickState();
if (props.open) void pollSelectionOdds();
},
);
onUnmounted(() => {
stopOddsPolling();
});
function close() {
emit('close');
}
function genRequestId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function setStake(amount: number) {
stake.value = Math.max(0.01, Math.round(amount * 100) / 100);
}
function setMaxStake() {
if (balance.value > 0) setStake(balance.value);
}
function validateSubmit(): boolean {
if (!props.pick || stake.value <= 0) return false;
if (stake.value > balance.value) {
error.value = t('bet.outright_insufficient');
return false;
}
if (hasSuspendedSelection.value) {
error.value = t('bet.odds_suspended');
return false;
}
return true;
}
function onSubmitClick() {
if (!props.pick) return;
if (!validateSubmit()) return;
if (hasPendingOddsChanges.value) {
acceptPendingOdds();
}
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
mode: 'single',
items: [
{
selectionId: props.pick.selectionId,
oddsVersion: currentOddsVersion.value,
matchId: '',
matchName: props.pick.eventTitle,
marketId: '',
marketName: props.pick.eventTitle,
selectionName: `${props.pick.teamName} · ${props.pick.eventTitle}`,
odds: oddsNum.value,
marketType: 'OUTRIGHT',
lineValue: null,
allowSingle: true,
allowParlay: false,
},
],
totalStake: stake.value,
totalReturn: estReturn.value,
formatMoney: (amount) => formatMoney(amount, locale.value),
getStake: () => stake.value,
getOdds: () => oddsNum.value,
});
showPlaceConfirm.value = true;
}
async function executeSubmit() {
if (!props.pick || stake.value <= 0) return;
loading.value = true;
error.value = '';
try {
await api.post('/player/bets/single', {
selectionId: props.pick.selectionId,
oddsVersion: currentOddsVersion.value,
stake: stake.value,
requestId: genRequestId(),
});
successStake.value = stake.value;
successBalance.value = balance.value - stake.value;
balance.value = successBalance.value;
step.value = 'success';
showSuccess.value = true;
void refreshProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string; code?: string } } };
if (err.response?.data?.code === 'ODDS_CHANGED') {
await pollSelectionOdds();
error.value = t('bet.odds_changed');
return;
}
error.value = err.response?.data?.error || t('bet.outright_bet_failed');
} finally {
loading.value = false;
}
}
async function confirmSubmit() {
showPlaceConfirm.value = false;
await executeSubmit();
}
function formatOdds(odds: string) {
const n = parseFloat(odds);
return Number.isFinite(n) ? n.toFixed(2) : odds;
}
</script>
<template>
<div v-if="open && pick" class="overlay" @click.self="close">
<div class="modal" role="dialog" aria-modal="true">
<button type="button" class="close-x" :aria-label="t('bet.cancel')" @click="close"></button>
<template v-if="step === 'form'">
<p class="hint">{{ t('bet.outright_enter_stake') }}</p>
<div class="hero">
<img v-if="flagUrl" :src="flagUrl" alt="" class="flag" />
<p class="team">{{ pick.teamName }}</p>
<span
class="odds-badge"
:class="{
'odds-badge--up': oddsDelta && !oddsDelta.suspended && oddsDelta.newOdds >= oddsDelta.oldOdds,
'odds-badge--down': oddsDelta && !oddsDelta.suspended && oddsDelta.newOdds < oddsDelta.oldOdds,
'odds-badge--suspended': oddsDelta?.suspended,
}"
>
<template v-if="oddsDelta && !oddsDelta.suspended">
@ {{ oddsDelta.oldOdds.toFixed(2) }} {{ oddsDelta.newOdds.toFixed(2) }}
</template>
<template v-else>
@ {{ formatOdds(currentOdds || pick.odds) }}
</template>
</span>
</div>
<p class="event-title">{{ pick.eventTitle }}</p>
<p v-if="oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
<div class="balance-row">
<span class="balance-label">{{ t('bet.outright_balance') }}</span>
<span class="balance-value">{{ balanceText }}</span>
</div>
<label class="stake-label">{{ t('bet.stake_label') }}</label>
<input
v-model.number="stake"
type="number"
class="stake-input"
min="0.01"
step="0.01"
inputmode="decimal"
:placeholder="t('bet.stake_placeholder')"
/>
<div class="quick-stakes">
<button type="button" class="chip" @click="setStake(50)">50</button>
<button type="button" class="chip" @click="setStake(100)">100</button>
<button type="button" class="chip" @click="setStake(500)">500</button>
<button type="button" class="chip" @click="setMaxStake">{{ t('bet.stake_max') }}</button>
</div>
<p class="est-return">
{{ t('history.est_return') }}<span class="est-value">{{ estReturnText }}</span>
</p>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn-cancel" :disabled="loading" @click="close">
{{ t('bet.cancel') }}
</button>
<button
type="button"
class="btn-confirm btn-gold-outline"
:disabled="loading || stake <= 0 || hasSuspendedSelection"
@click="onSubmitClick"
>
{{ submitButtonLabel }}
</button>
</div>
</template>
<template v-else>
<div class="success-icon"></div>
<h3 class="success-title">{{ t('bet.outright_success') }}</h3>
<div class="hero hero--compact">
<img v-if="flagUrl" :src="flagUrl" alt="" class="flag" />
<p class="team">{{ pick.teamName }}</p>
<span class="odds-badge">@ {{ formatOdds(pick.odds) }}</span>
</div>
<p class="event-title">{{ pick.eventTitle }}</p>
<div class="summary">
<div class="summary-row">
<span>{{ t('bet.outright_stake_amount') }}</span>
<span>{{ formatMoney(successStake, locale) }}</span>
</div>
<div class="summary-row">
<span>{{ t('bet.outright_balance') }}</span>
<span class="balance-value">{{ formatMoney(successBalance, locale) }}</span>
</div>
</div>
<button type="button" class="btn-done btn-gold-outline" @click="close">
{{ t('bet.outright_done') }}
</button>
</template>
</div>
</div>
<BetSuccessOverlay :show="showSuccess" @done="showSuccess = false" />
<ConfirmDialog
v-model:visible="showPlaceConfirm"
:title="t('bet.place_confirm_title')"
:message="placeConfirmMessage"
:confirm-text="t('bet.place_bet')"
:cancel-text="t('common.cancel')"
:loading="loading"
@confirm="confirmSubmit"
/>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(4px);
}
.modal {
position: relative;
width: 100%;
max-width: 300px;
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
border: 1px solid var(--border-gold-soft);
border-radius: var(--radius);
padding: 18px 16px 16px;
text-align: center;
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
}
.close-x {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
color: var(--text-muted);
font-size: 14px;
line-height: 1;
}
.hint {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 12px;
letter-spacing: 0.04em;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.hero--compact {
margin-top: 4px;
margin-bottom: 8px;
}
.flag {
width: 48px;
height: 32px;
object-fit: cover;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
}
.team {
font-size: 20px;
font-weight: 900;
color: var(--primary-light);
letter-spacing: 0.02em;
}
.odds-badge {
display: inline-block;
padding: 3px 10px;
background: rgba(198, 40, 40, 0.85);
border: 1px solid rgba(255, 120, 120, 0.35);
color: #fff;
font-size: 13px;
font-weight: 800;
border-radius: 4px;
}
.odds-badge--up {
background: rgba(22, 101, 52, 0.9);
border-color: rgba(110, 231, 160, 0.35);
}
.odds-badge--down {
background: rgba(153, 27, 27, 0.92);
border-color: rgba(255, 139, 139, 0.4);
}
.odds-badge--suspended {
background: rgba(120, 83, 14, 0.9);
border-color: rgba(255, 184, 77, 0.35);
}
.odds-warning {
margin: 0 0 10px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(244, 162, 97, 0.12);
border: 1px solid rgba(244, 162, 97, 0.28);
color: var(--primary-light);
font-size: 12px;
line-height: 1.45;
}
.event-title {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 12px;
line-height: 1.4;
padding: 0 4px;
}
.balance-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
margin-bottom: 12px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--border);
border-radius: 6px;
}
.balance-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.balance-value {
font-size: 14px;
font-weight: 800;
color: var(--primary-light);
}
.stake-label {
display: block;
text-align: left;
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 6px;
}
.stake-input {
width: 100%;
padding: 11px 12px;
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text);
font-size: 18px;
font-weight: 800;
text-align: center;
outline: none;
}
.stake-input:focus {
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.15);
}
.stake-input::placeholder {
color: #555;
font-weight: 500;
font-size: 14px;
}
.quick-stakes {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.chip {
flex: 1;
padding: 6px 4px;
border-radius: 6px;
border: 1px solid var(--border);
background: #141414;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
}
.chip:active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
}
.est-return {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 12px;
}
.est-value {
color: var(--primary-light);
font-weight: 800;
}
.error {
color: var(--danger);
font-size: 12px;
font-weight: 600;
margin-bottom: 10px;
}
.actions {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 8px;
}
.btn-cancel {
padding: 10px;
border-radius: 6px;
background: #1a1a1a;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
}
.btn-confirm {
padding: 10px;
border-radius: 6px;
font-size: 14px;
font-weight: 800;
}
.btn-confirm:disabled {
opacity: 0.45;
}
.success-icon {
width: 44px;
height: 44px;
margin: 0 auto 8px;
border-radius: 50%;
border: 2px solid rgba(46, 158, 94, 0.8);
background: rgba(46, 158, 94, 0.12);
color: #4ade80;
font-size: 24px;
font-weight: 900;
line-height: 40px;
}
.success-title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
margin-bottom: 10px;
}
.summary {
margin: 12px 0 14px;
padding: 10px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--border);
border-radius: 6px;
}
.summary-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--text-muted);
padding: 4px 0;
}
.summary-row + .summary-row {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.btn-done {
width: 100%;
padding: 11px;
border-radius: 6px;
font-size: 14px;
font-weight: 800;
}
</style>