fix(player): 冠军盘弹窗支持赔率变更轮询与确认下注

- 打开弹窗后定时拉取最新赔率并展示变更提示
- 赔率变动时按钮切换为「接受变更并下注」,提交前自动锁定新版本
- 接口返回 ODDS_CHANGED 时刷新赔率并引导用户重新确认
This commit is contained in:
2026-06-18 13:46:44 +08:00
parent 33e7092b77
commit 3212cd6040

View File

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