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>

View 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>

View 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>