feat(player): 完善 H5 投注端与 API 演示数据

- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑
- 固定顶栏、公告与底栏,仅内容区滚动
- 底部导航与站点 favicon 使用 logo,登录页精简
- API 种子、冠军盘与历史注单增强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 17:18:11 +08:00
parent 7af2e418c3
commit b5dca1bfb1
75 changed files with 7077 additions and 384 deletions

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import { formatMoney, parseAmount } from '../../utils/localeDisplay';
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 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 balanceText = computed(() => formatMoney(balance.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)}`;
}
async function submit() {
if (!props.pick || stake.value <= 0) 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';
} 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">
<template v-if="step === 'form'">
<h3 class="title">{{ t('bet.outright_enter_stake') }}</h3>
<p class="team">{{ pick.teamName }}</p>
<span class="odds-badge">{{ formatOdds(pick.odds) }}</span>
<p class="balance-line">{{ t('bet.outright_balance') }}{{ balanceText }}</p>
<p class="event-title">{{ pick.eventTitle }}</p>
<input
v-model.number="stake"
type="number"
class="stake-input"
min="0.01"
step="0.01"
inputmode="decimal"
/>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn-confirm" :disabled="loading" @click="submit">
{{ t('bet.place_bet_short') }}
</button>
<button type="button" class="btn-cancel" :disabled="loading" @click="close">
{{ t('bet.cancel') }}
</button>
</div>
</template>
<template v-else>
<div class="success-icon"></div>
<h3 class="title success-title">{{ t('bet.outright_success') }}</h3>
<p class="team">{{ pick.teamName }}</p>
<span class="odds-badge">{{ formatOdds(pick.odds) }}</span>
<p class="balance-line">
{{ t('bet.outright_stake_amount') }} : {{ successStake.toFixed(2) }}
</p>
<p class="balance-line">
{{ t('bet.outright_balance') }} : {{ formatMoney(successBalance, locale) }}
</p>
<p class="event-title">{{ pick.eventTitle }}</p>
<button type="button" class="btn-share" disabled>Share</button>
<button type="button" class="btn-done" @click="close">{{ t('bet.outright_done') }}</button>
</template>
</div>
</div>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal {
width: 100%;
max-width: 320px;
background: #f5f5f5;
border-radius: 8px;
padding: 20px 18px 16px;
text-align: center;
color: #333;
}
.title {
font-size: 15px;
font-weight: 800;
color: #b8942b;
margin-bottom: 10px;
}
.success-title {
margin-top: 8px;
}
.team {
font-size: 22px;
font-weight: 900;
color: #c9a227;
margin-bottom: 8px;
}
.odds-badge {
display: inline-block;
padding: 2px 10px;
background: #c62828;
color: #fff;
font-size: 13px;
font-weight: 800;
border-radius: 3px;
margin-bottom: 12px;
}
.balance-line {
font-size: 13px;
color: #444;
margin-bottom: 6px;
}
.event-title {
font-size: 12px;
color: #666;
margin: 8px 0 14px;
line-height: 1.35;
}
.stake-input {
width: 100%;
padding: 10px;
border: 2px solid #5b9bd5;
border-radius: 4px;
font-size: 16px;
font-weight: 700;
text-align: center;
margin-bottom: 12px;
color: #111;
}
.error {
color: #c62828;
font-size: 12px;
margin-bottom: 8px;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.btn-confirm {
padding: 10px;
border-radius: 4px;
background: #2e9e5e;
color: #fff;
font-size: 15px;
font-weight: 800;
}
.btn-cancel {
padding: 10px;
border-radius: 4px;
background: #555;
color: #fff;
font-size: 15px;
font-weight: 800;
}
.success-icon {
width: 48px;
height: 48px;
margin: 0 auto 4px;
border-radius: 50%;
border: 3px solid #2e9e5e;
color: #2e9e5e;
font-size: 28px;
font-weight: 900;
line-height: 42px;
}
.btn-share {
width: 100%;
margin: 12px 0 8px;
padding: 10px;
border-radius: 20px;
background: #1877f2;
color: #fff;
font-weight: 700;
opacity: 0.5;
}
.btn-done {
width: 100%;
padding: 12px;
border-radius: 4px;
background: #1aa89a;
color: #fff;
font-size: 16px;
font-weight: 800;
}
</style>