feat(admin,api,player): 优胜赛配置、赛事管理重构与玩家端投注体验优化

管理端拆分赛事/优胜赛 Tab,新增联赛优胜赔率面板(批量、排序、外侧删除);统一 list-chrome 工具栏对齐与列表页布局;Dashboard 失败重试、Users 操作下拉、小屏侧栏等体验修复。

API 扩展优胜赛与赛事目录接口,完善投注与钱包查询;玩家端重构赛事卡片、串关面板、注单/钱包页,新增注单详情、下注成功动画与下拉刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 09:55:56 +08:00
parent efff7c27e6
commit 24fa1b275c
66 changed files with 6289 additions and 1426 deletions

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
import { useBetSlipStore } from '../stores/betSlip';
import BetSuccessOverlay from './BetSuccessOverlay.vue';
import api from '../api';
const props = defineProps<{ modelValue: boolean }>();
@@ -18,11 +19,18 @@ const show = computed({
const loading = ref(false);
const error = ref('');
const success = ref('');
const showSuccess = ref(false);
function genId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function onSuccessDone() {
showSuccess.value = false;
show.value = false;
success.value = '';
}
async function placeBet() {
if (!slip.items.length) return;
loading.value = true;
@@ -58,10 +66,14 @@ async function placeBet() {
}
success.value = t('bet.place_success');
slip.clear();
showSuccess.value = true;
setTimeout(() => {
show.value = false;
success.value = '';
}, 1500);
if (showSuccess.value) {
showSuccess.value = false;
show.value = false;
success.value = '';
}
}, 2500);
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
@@ -75,58 +87,64 @@ async function placeBet() {
<template>
<div v-if="show" class="overlay" @click.self="show = false">
<div class="drawer">
<div class="drawer-header">
<h3>{{ t('bet.bet_slip') }} <span class="count">({{ slip.count }})</span></h3>
<button type="button" class="close-btn" :aria-label="t('bet.cancel')" @click="show = false">
<div class="drawer-body">
<div class="drawer-header">
<h3>{{ t('bet.bet_slip') }} <span class="count">({{ slip.count }})</span></h3>
<button type="button" class="close-btn" :aria-label="t('bet.cancel')" @click="show = false">
</button>
</div>
<div v-if="!slip.items.length" class="empty">{{ t('bet.slip_empty_hint') }}</div>
<div v-for="item in slip.items" :key="item.selectionId" class="slip-item">
<div class="item-name">{{ item.matchName }}</div>
<div class="item-sel">
{{ item.selectionName }} @ <span class="odds">{{ item.odds }}</span>
</div>
<button type="button" class="remove" @click="slip.removeItem(item.selectionId)">
{{ t('bet.slip_remove') }}
</button>
</div>
<p v-if="slip.canPlaceParlay" class="mode-hint mode-hint--parlay">
{{ t('bet.parlay') }} · {{ t('bet.slip_parlay_odds', { odds: slip.totalOdds.toFixed(2) }) }}
</p>
<p v-else-if="slip.canPlaceBatchSingles && slip.count > 1" class="mode-hint">
{{ t('bet.slip_singles_hint', { n: slip.count }) }}
</p>
<div v-if="slip.items.length" class="stake-area">
<label>{{
slip.canPlaceBatchSingles && slip.count > 1
? t('bet.slip_stake_per_bet')
: t('bet.stake')
}}</label>
<input v-model.number="slip.stake" type="number" min="1" />
<div class="return">
{{ t('bet.slip_est_return') }}:
<strong>{{ slip.potentialReturn.toFixed(2) }}</strong>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="success" class="success">{{ success }}</p>
</div>
<div class="drawer-foot">
<button
type="button"
class="btn-primary"
:disabled="loading || !slip.canSubmit"
@click="placeBet"
>
{{ loading ? t('bet.placing') : t('bet.place_bet') }}
</button>
</div>
<div v-if="!slip.items.length" class="empty">{{ t('bet.slip_empty_hint') }}</div>
<div v-for="item in slip.items" :key="item.selectionId" class="slip-item">
<div class="item-name">{{ item.matchName }}</div>
<div class="item-sel">
{{ item.selectionName }} @ <span class="odds">{{ item.odds }}</span>
</div>
<button type="button" class="remove" @click="slip.removeItem(item.selectionId)">
{{ t('bet.slip_remove') }}
</button>
</div>
<p v-if="slip.canPlaceParlay" class="mode-hint mode-hint--parlay">
{{ t('bet.parlay') }} · {{ t('bet.slip_parlay_odds', { odds: slip.totalOdds.toFixed(2) }) }}
</p>
<p v-else-if="slip.canPlaceBatchSingles && slip.count > 1" class="mode-hint">
{{ t('bet.slip_singles_hint', { n: slip.count }) }}
</p>
<div v-if="slip.items.length" class="stake-area">
<label>{{
slip.canPlaceBatchSingles && slip.count > 1
? t('bet.slip_stake_per_bet')
: t('bet.stake')
}}</label>
<input v-model.number="slip.stake" type="number" min="1" />
<div class="return">
{{ t('bet.slip_est_return') }}:
<strong>{{ slip.potentialReturn.toFixed(2) }}</strong>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="success" class="success">{{ success }}</p>
<button
type="button"
class="btn-primary"
:disabled="loading || !slip.canSubmit"
@click="placeBet"
>
{{ loading ? t('bet.placing') : t('bet.place_bet') }}
</button>
</div>
</div>
<BetSuccessOverlay :show="showSuccess" @done="onSuccessDone" />
</template>
<style scoped>
@@ -144,9 +162,25 @@ async function placeBet() {
width: 100%;
max-height: 80vh;
border-radius: 16px 16px 0 0;
padding: 14px 14px calc(14px + env(safe-area-inset-bottom, 0));
overflow-y: auto;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 14px 14px 0;
-webkit-overflow-scrolling: touch;
}
.drawer-foot {
flex-shrink: 0;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(14, 14, 14, 0.98);
border-top: 1px solid var(--border-gold-soft);
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
}
.drawer-header {