fix(player): 冠军盘弹窗支持赔率变更轮询与确认下注
- 打开弹窗后定时拉取最新赔率并展示变更提示 - 赔率变动时按钮切换为「接受变更并下注」,提交前自动锁定新版本 - 接口返回 ODDS_CHANGED 时刷新赔率并引导用户重新确认
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { usePlayerProfile } from '../../composables/usePlayerProfile';
|
import { usePlayerProfile } from '../../composables/usePlayerProfile';
|
||||||
@@ -16,6 +16,23 @@ export interface OutrightPick {
|
|||||||
eventTitle: 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<{
|
const props = defineProps<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
pick: OutrightPick | null;
|
pick: OutrightPick | null;
|
||||||
@@ -34,6 +51,11 @@ const balance = ref(0);
|
|||||||
const successBalance = ref(0);
|
const successBalance = ref(0);
|
||||||
const successStake = ref(0);
|
const successStake = ref(0);
|
||||||
const showSuccess = ref(false);
|
const showSuccess = ref(false);
|
||||||
|
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(() =>
|
const flagUrl = computed(() =>
|
||||||
props.pick ? teamFlagUrl(props.pick.teamCode, props.pick.teamName) : null,
|
props.pick ? teamFlagUrl(props.pick.teamCode, props.pick.teamName) : null,
|
||||||
@@ -41,12 +63,14 @@ const flagUrl = computed(() =>
|
|||||||
|
|
||||||
const balanceText = computed(() => formatMoney(balance.value, locale.value));
|
const balanceText = computed(() => formatMoney(balance.value, locale.value));
|
||||||
|
|
||||||
const oddsNum = computed(() => {
|
const effectiveOdds = computed(() => {
|
||||||
if (!props.pick) return 0;
|
if (oddsDelta.value && !oddsDelta.value.suspended) return oddsDelta.value.newOdds;
|
||||||
const n = parseFloat(props.pick.odds);
|
const n = parseFloat(currentOdds.value);
|
||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const oddsNum = computed(() => effectiveOdds.value);
|
||||||
|
|
||||||
const estReturn = computed(() => {
|
const estReturn = computed(() => {
|
||||||
const s = Number(stake.value);
|
const s = Number(stake.value);
|
||||||
if (!s || s <= 0 || !oddsNum.value) return 0;
|
if (!s || s <= 0 || !oddsNum.value) return 0;
|
||||||
@@ -55,22 +79,120 @@ const estReturn = computed(() => {
|
|||||||
|
|
||||||
const estReturnText = computed(() => formatMoney(estReturn.value, locale.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(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
async (v) => {
|
async (v) => {
|
||||||
if (!v) return;
|
if (!v) {
|
||||||
|
stopOddsPolling();
|
||||||
|
oddsDelta.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
step.value = 'form';
|
step.value = 'form';
|
||||||
stake.value = 1;
|
stake.value = 1;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
|
syncPickState();
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/player/profile');
|
const { data } = await api.get('/player/profile');
|
||||||
balance.value = parseAmount(data.data?.wallet?.availableBalance);
|
balance.value = parseAmount(data.data?.wallet?.availableBalance);
|
||||||
} catch {
|
} catch {
|
||||||
balance.value = 0;
|
balance.value = 0;
|
||||||
}
|
}
|
||||||
|
startOddsPolling();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.pick?.selectionId,
|
||||||
|
() => {
|
||||||
|
if (!props.open) return;
|
||||||
|
syncPickState();
|
||||||
|
if (props.open) void pollSelectionOdds();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopOddsPolling();
|
||||||
|
});
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('close');
|
emit('close');
|
||||||
}
|
}
|
||||||
@@ -93,12 +215,20 @@ async function submit() {
|
|||||||
error.value = t('bet.outright_insufficient');
|
error.value = t('bet.outright_insufficient');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasSuspendedSelection.value) {
|
||||||
|
error.value = t('bet.odds_suspended');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasPendingOddsChanges.value) {
|
||||||
|
acceptPendingOdds();
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
await api.post('/player/bets/single', {
|
await api.post('/player/bets/single', {
|
||||||
selectionId: props.pick.selectionId,
|
selectionId: props.pick.selectionId,
|
||||||
oddsVersion: props.pick.oddsVersion,
|
oddsVersion: currentOddsVersion.value,
|
||||||
stake: stake.value,
|
stake: stake.value,
|
||||||
requestId: genRequestId(),
|
requestId: genRequestId(),
|
||||||
});
|
});
|
||||||
@@ -109,9 +239,13 @@ async function submit() {
|
|||||||
showSuccess.value = true;
|
showSuccess.value = true;
|
||||||
void refreshProfile();
|
void refreshProfile();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value =
|
const err = e as { response?: { data?: { error?: string; code?: string } } };
|
||||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
if (err.response?.data?.code === 'ODDS_CHANGED') {
|
||||||
t('bet.outright_bet_failed');
|
await pollSelectionOdds();
|
||||||
|
error.value = t('bet.odds_changed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = err.response?.data?.error || t('bet.outright_bet_failed');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -134,11 +268,27 @@ function formatOdds(odds: string) {
|
|||||||
<div class="hero">
|
<div class="hero">
|
||||||
<img v-if="flagUrl" :src="flagUrl" alt="" class="flag" />
|
<img v-if="flagUrl" :src="flagUrl" alt="" class="flag" />
|
||||||
<p class="team">{{ pick.teamName }}</p>
|
<p class="team">{{ pick.teamName }}</p>
|
||||||
<span class="odds-badge">@ {{ formatOdds(pick.odds) }}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p class="event-title">{{ pick.eventTitle }}</p>
|
<p class="event-title">{{ pick.eventTitle }}</p>
|
||||||
|
|
||||||
|
<p v-if="oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
|
||||||
|
|
||||||
<div class="balance-row">
|
<div class="balance-row">
|
||||||
<span class="balance-label">{{ t('bet.outright_balance') }}</span>
|
<span class="balance-label">{{ t('bet.outright_balance') }}</span>
|
||||||
<span class="balance-value">{{ balanceText }}</span>
|
<span class="balance-value">{{ balanceText }}</span>
|
||||||
@@ -175,10 +325,10 @@ function formatOdds(odds: string) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-confirm btn-gold-outline"
|
class="btn-confirm btn-gold-outline"
|
||||||
:disabled="loading || stake <= 0"
|
:disabled="loading || stake <= 0 || hasSuspendedSelection"
|
||||||
@click="submit"
|
@click="submit"
|
||||||
>
|
>
|
||||||
{{ loading ? t('bet.placing') : t('bet.place_bet_short') }}
|
{{ submitButtonLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -301,6 +451,32 @@ function formatOdds(odds: string) {
|
|||||||
border-radius: 4px;
|
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 {
|
.event-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #6B7280;
|
color: #6B7280;
|
||||||
|
|||||||
Reference in New Issue
Block a user