feat(admin,api,player): 结算预览分页、统计图表与返水限额
完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,19 +30,11 @@ async function placeBet() {
|
||||
success.value = '';
|
||||
|
||||
try {
|
||||
if (slip.mode === 'parlay' && slip.items.length >= PARLAY_MIN_LEGS) {
|
||||
if (slip.hasSameMatch) {
|
||||
error.value = t('bet.parlay_same_match');
|
||||
return;
|
||||
}
|
||||
if (slip.canPlaceParlay) {
|
||||
if (slip.items.length > PARLAY_MAX_LEGS) {
|
||||
error.value = t('bet.parlay_max_legs');
|
||||
return;
|
||||
}
|
||||
if (!slip.canPlaceParlay) {
|
||||
error.value = t('bet.parlay_need_more');
|
||||
return;
|
||||
}
|
||||
await api.post('/player/bets/parlay', {
|
||||
legs: slip.items.map((i) => ({
|
||||
selectionId: i.selectionId,
|
||||
@@ -51,15 +43,17 @@ async function placeBet() {
|
||||
stake: slip.stake,
|
||||
requestId: genId(),
|
||||
});
|
||||
} else if (slip.items.length === 1) {
|
||||
const item = slip.items[0];
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
stake: slip.stake,
|
||||
requestId: genId(),
|
||||
});
|
||||
} else if (slip.canPlaceBatchSingles) {
|
||||
for (const item of slip.items) {
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
stake: slip.stake,
|
||||
requestId: genId(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
error.value = t('bet.parlay_need_more');
|
||||
return;
|
||||
}
|
||||
success.value = t('bet.place_success');
|
||||
@@ -100,12 +94,19 @@ async function placeBet() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="slip.isParlay" class="mode-hint mode-hint--parlay">
|
||||
<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>{{ t('bet.stake') }}</label>
|
||||
<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') }}:
|
||||
@@ -119,7 +120,7 @@ async function placeBet() {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="loading || !slip.items.length || (slip.mode === 'parlay' && !slip.canPlaceParlay)"
|
||||
:disabled="loading || !slip.canSubmit"
|
||||
@click="placeBet"
|
||||
>
|
||||
{{ loading ? t('bet.placing') : t('bet.place_bet') }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { teamFlagUrl } from '../utils/teamFlag';
|
||||
import TeamEmblem from './TeamEmblem.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
match: {
|
||||
@@ -31,12 +31,6 @@ const kickoff = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const homeFlag = computed(() =>
|
||||
teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName, props.match.homeTeamLogoUrl),
|
||||
);
|
||||
const awayFlag = computed(() =>
|
||||
teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName, props.match.awayTeamLogoUrl),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,12 +38,22 @@ const awayFlag = computed(() =>
|
||||
<div class="kickoff">{{ kickoff }}</div>
|
||||
<div class="teams-stack">
|
||||
<div class="side">
|
||||
<img v-if="homeFlag" :src="homeFlag" alt="" class="flag" />
|
||||
<TeamEmblem
|
||||
size="sm"
|
||||
:team-code="match.homeTeamCode"
|
||||
:team-name="match.homeTeamName"
|
||||
:logo-url="match.homeTeamLogoUrl"
|
||||
/>
|
||||
<span class="name">{{ match.homeTeamName }}</span>
|
||||
</div>
|
||||
<span class="vs">VS</span>
|
||||
<div class="side">
|
||||
<img v-if="awayFlag" :src="awayFlag" alt="" class="flag" />
|
||||
<TeamEmblem
|
||||
size="sm"
|
||||
:team-code="match.awayTeamCode"
|
||||
:team-name="match.awayTeamName"
|
||||
:logo-url="match.awayTeamLogoUrl"
|
||||
/>
|
||||
<span class="name">{{ match.awayTeamName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,14 +102,6 @@ const awayFlag = computed(() =>
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flag {
|
||||
width: 22px;
|
||||
height: 15px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
|
||||
121
apps/player/src/components/TeamEmblem.vue
Normal file
121
apps/player/src/components/TeamEmblem.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { teamFlagUrl } from '../utils/teamFlag';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
teamCode?: string;
|
||||
teamName?: string;
|
||||
logoUrl?: string | null;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}>(),
|
||||
{ size: 'md' },
|
||||
);
|
||||
|
||||
const useCustomLogo = computed(() => Boolean(props.logoUrl?.trim()));
|
||||
const src = computed(() =>
|
||||
teamFlagUrl(props.teamCode, props.teamName, props.logoUrl),
|
||||
);
|
||||
const failed = ref(false);
|
||||
|
||||
function onError() {
|
||||
failed.value = true;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.teamCode, props.teamName, props.logoUrl] as const,
|
||||
() => {
|
||||
failed.value = false;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
v-if="src && !failed"
|
||||
:src="src"
|
||||
alt=""
|
||||
class="team-emblem"
|
||||
:class="[`team-emblem--${size}`, { 'team-emblem--logo': useCustomLogo }]"
|
||||
loading="lazy"
|
||||
@error="onError"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="team-emblem team-emblem--placeholder"
|
||||
:class="`team-emblem--${size}`"
|
||||
aria-hidden="true"
|
||||
>⚽</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.team-emblem {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.team-emblem--sm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.team-emblem--md {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.team-emblem--lg {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
/* 国旗:横向比例 + 铺满 */
|
||||
.team-emblem:not(.team-emblem--logo) {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.team-emblem--sm:not(.team-emblem--logo) {
|
||||
width: 28px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.team-emblem--md:not(.team-emblem--logo) {
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.team-emblem--lg:not(.team-emblem--logo) {
|
||||
width: 54px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* 队徽:正方形容器 + 完整显示 */
|
||||
.team-emblem--logo {
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.team-emblem--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.55;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.team-emblem--sm.team-emblem--placeholder {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-emblem--md.team-emblem--placeholder {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.team-emblem--lg.team-emblem--placeholder {
|
||||
font-size: 22px;
|
||||
}
|
||||
</style>
|
||||
@@ -159,6 +159,10 @@ function openSlip() {
|
||||
}
|
||||
|
||||
const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
|
||||
const footConfirmLabel = computed(() =>
|
||||
slip.canPlaceParlay ? t('bet.parlay_confirm_parlay') : t('bet.cs_confirm_cell'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -247,7 +251,7 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
:disabled="!slip.canPlaceParlay"
|
||||
@click="openSlip"
|
||||
>
|
||||
{{ t('bet.cs_confirm_cell') }}
|
||||
{{ footConfirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -290,10 +294,15 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
|
||||
.foot-hint--warn {
|
||||
color: var(--danger);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.foot-hint--info {
|
||||
color: var(--primary-light);
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.foot-meta {
|
||||
color: var(--primary-light);
|
||||
font-weight: 600;
|
||||
|
||||
Reference in New Issue
Block a user