feat(player): 完善 H5 投注端与 API 演示数据
- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑 - 固定顶栏、公告与底栏,仅内容区滚动 - 底部导航与站点 favicon 使用 logo,登录页精简 - API 种子、冠军盘与历史注单增强 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
274
apps/player/src/components/outright/OutrightBetModal.vue
Normal file
274
apps/player/src/components/outright/OutrightBetModal.vue
Normal 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>
|
||||
72
apps/player/src/components/outright/OutrightOptionCard.vue
Normal file
72
apps/player/src/components/outright/OutrightOptionCard.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { teamFlagUrl } from '../../utils/teamFlag';
|
||||
|
||||
const props = defineProps<{
|
||||
teamCode: string;
|
||||
teamName: string;
|
||||
odds: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ pick: [] }>();
|
||||
|
||||
const flag = computed(() => teamFlagUrl(props.teamCode, props.teamName));
|
||||
|
||||
function formatOdds(odds: string) {
|
||||
const n = parseFloat(odds);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : odds;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="option-card" @click="emit('pick')">
|
||||
<img v-if="flag" :src="flag" alt="" class="flag" />
|
||||
<span class="name">{{ teamName }}</span>
|
||||
<span class="odds">[ {{ formatOdds(odds) }} ]</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.option-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 4px 6px;
|
||||
border-radius: 6px;
|
||||
background: #1c1c1c;
|
||||
border: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.option-card:active {
|
||||
background: #252525;
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.flag {
|
||||
width: 28px;
|
||||
height: 19px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
line-height: 1.2;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.odds {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
</style>
|
||||
188
apps/player/src/components/outright/OutrightPanel.vue
Normal file
188
apps/player/src/components/outright/OutrightPanel.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../../api';
|
||||
import OutrightOptionCard from './OutrightOptionCard.vue';
|
||||
import OutrightBetModal, { type OutrightPick } from './OutrightBetModal.vue';
|
||||
import emptyMatchesImg from '../../assets/images/empty-matches.svg';
|
||||
import saishiImg from '../../assets/images/saishi.png';
|
||||
|
||||
interface OutrightSelection {
|
||||
id: string;
|
||||
teamCode: string;
|
||||
teamName: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}
|
||||
|
||||
interface OutrightEvent {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
title: string;
|
||||
selections: OutrightSelection[];
|
||||
}
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const loading = ref(true);
|
||||
const events = ref<OutrightEvent[]>([]);
|
||||
const expanded = ref<Set<string>>(new Set());
|
||||
const modalOpen = ref(false);
|
||||
const activePick = ref<OutrightPick | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/outrights');
|
||||
events.value = data.data ?? [];
|
||||
if (events.value.length && expanded.value.size === 0) {
|
||||
expanded.value = new Set([events.value[0].id]);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
function toggle(id: string) {
|
||||
const next = new Set(expanded.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
expanded.value = next;
|
||||
}
|
||||
|
||||
function openBet(event: OutrightEvent, sel: OutrightSelection) {
|
||||
activePick.value = {
|
||||
selectionId: sel.id,
|
||||
oddsVersion: sel.oddsVersion,
|
||||
teamCode: sel.teamCode,
|
||||
teamName: sel.teamName,
|
||||
odds: sel.odds,
|
||||
eventTitle: event.title,
|
||||
};
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen.value = false;
|
||||
activePick.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="outright-panel">
|
||||
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
|
||||
|
||||
<div v-else-if="events.length" class="event-list">
|
||||
<section v-for="event in events" :key="event.id" class="event-block">
|
||||
<button type="button" class="event-head" @click="toggle(event.id)">
|
||||
<span class="toggle-icon">
|
||||
<span class="toggle-mark">{{ expanded.has(event.id) ? '−' : '+' }}</span>
|
||||
</span>
|
||||
<span class="event-title">{{ event.title }}</span>
|
||||
<img :src="saishiImg" alt="" class="event-saishi" />
|
||||
</button>
|
||||
|
||||
<div v-if="expanded.has(event.id)" class="options-grid">
|
||||
<OutrightOptionCard
|
||||
v-for="sel in event.selections"
|
||||
:key="sel.id"
|
||||
:team-code="sel.teamCode"
|
||||
:team-name="sel.teamName"
|
||||
:odds="sel.odds"
|
||||
@pick="openBet(event, sel)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
|
||||
<p>{{ t('bet.no_outright') }}</p>
|
||||
</div>
|
||||
|
||||
<OutrightBetModal :open="modalOpen" :pick="activePick" @close="closeModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outright-panel {
|
||||
padding: 4px 12px 0;
|
||||
}
|
||||
|
||||
.event-block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.event-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 10px;
|
||||
background: #141414;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #141414;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toggle-mark {
|
||||
color: var(--primary-light);
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.event-saishi {
|
||||
flex-shrink: 0;
|
||||
height: 44px;
|
||||
width: auto;
|
||||
max-width: 40px;
|
||||
object-fit: contain;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 10px 0 4px;
|
||||
}
|
||||
|
||||
.state,
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 48px 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user