Files
thebet365/apps/player/src/components/outright/OutrightBetModal.vue
Mars afb5c5437e feat: 充值订单审计与重新申请,优化赛事展示和余额刷新
- 新增 deposit_order_audit_logs 表,记录提交/审批/拒绝/撤销/重提全链路
- 管理端充值单页增加审计历史;玩家端充值历史支持时间线与重新申请
- 已拒绝订单可原单号重提;撤销入账使用 PLAYER_DEPOSIT_REVERSAL 并加强幂等
- 结算后清除热门标记,允许归档已结算赛事,完善今日赛事时区窗口
- 足球页今日/早盘独立折叠;资料与余额在进入钱包/个人页及下注后自动刷新
- 补充投注玩法、结算返水规则文档;新增 smoke/settlement CLI 脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 14:52:05 +08:00

487 lines
11 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 } 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';
export interface OutrightPick {
selectionId: string;
oddsVersion: string;
teamCode: string;
teamName: string;
odds: string;
eventTitle: string;
}
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 flagUrl = computed(() =>
props.pick ? teamFlagUrl(props.pick.teamCode, props.pick.teamName) : null,
);
const balanceText = computed(() => formatMoney(balance.value, locale.value));
const oddsNum = computed(() => {
if (!props.pick) return 0;
const n = parseFloat(props.pick.odds);
return Number.isFinite(n) ? n : 0;
});
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));
watch(
() => props.open,
async (v) => {
if (!v) return;
step.value = 'form';
stake.value = 1;
error.value = '';
try {
const { data } = await api.get('/player/profile');
balance.value = parseAmount(data.data?.wallet?.availableBalance);
} catch {
balance.value = 0;
}
},
);
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);
}
async function submit() {
if (!props.pick || stake.value <= 0) return;
if (stake.value > balance.value) {
error.value = t('bet.outright_insufficient');
return;
}
loading.value = true;
error.value = '';
try {
await api.post('/player/bets/single', {
selectionId: props.pick.selectionId,
oddsVersion: props.pick.oddsVersion,
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) {
error.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
t('bet.outright_bet_failed');
} finally {
loading.value = false;
}
}
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">@ {{ formatOdds(pick.odds) }}</span>
</div>
<p class="event-title">{{ pick.eventTitle }}</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"
@click="submit"
>
{{ loading ? t('bet.placing') : t('bet.place_bet_short') }}
</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" />
</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;
}
.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>