feat(admin,api,player): 优胜赛配置、赛事管理重构与玩家端投注体验优化
管理端拆分赛事/优胜赛 Tab,新增联赛优胜赔率面板(批量、排序、外侧删除);统一 list-chrome 工具栏对齐与列表页布局;Dashboard 失败重试、Users 操作下拉、小屏侧栏等体验修复。 API 扩展优胜赛与赛事目录接口,完善投注与钱包查询;玩家端重构赛事卡片、串关面板、注单/钱包页,新增注单详情、下注成功动画与下拉刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import api from '../../api';
|
||||
import { formatMoney, parseAmount } from '../../utils/localeDisplay';
|
||||
import { teamFlagUrl } from '../../utils/teamFlag';
|
||||
import BetSuccessOverlay from '../BetSuccessOverlay.vue';
|
||||
|
||||
export interface OutrightPick {
|
||||
selectionId: string;
|
||||
@@ -30,6 +31,7 @@ const error = ref('');
|
||||
const balance = ref(0);
|
||||
const successBalance = ref(0);
|
||||
const successStake = ref(0);
|
||||
const showSuccess = ref(false);
|
||||
|
||||
const flagUrl = computed(() =>
|
||||
props.pick ? teamFlagUrl(props.pick.teamCode, props.pick.teamName) : null,
|
||||
@@ -102,6 +104,7 @@ async function submit() {
|
||||
successBalance.value = balance.value - stake.value;
|
||||
balance.value = successBalance.value;
|
||||
step.value = 'success';
|
||||
showSuccess.value = true;
|
||||
} catch (e: unknown) {
|
||||
error.value =
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||
@@ -206,6 +209,8 @@ function formatOdds(odds: string) {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BetSuccessOverlay :show="showSuccess" @done="showSuccess = false" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -26,20 +26,15 @@ export interface OutrightEvent {
|
||||
const props = defineProps<{
|
||||
event: OutrightEvent;
|
||||
expanded: boolean;
|
||||
visibleLimit: number;
|
||||
loadingMore: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [];
|
||||
loadMore: [];
|
||||
pick: [selection: OutrightSelection];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const INITIAL_BATCH = 20;
|
||||
|
||||
const headTitle = computed(() => {
|
||||
const raw = props.event.title.replace(/^\*+/, '').trim();
|
||||
return raw || props.event.leagueName || t('bet.tab_outright');
|
||||
@@ -49,28 +44,16 @@ const headMeta = computed(() => {
|
||||
const total = props.event.selectionCount ?? props.event.selections.length;
|
||||
return t('bet.outright_teams_count', { n: total });
|
||||
});
|
||||
|
||||
const visibleSelections = computed(() =>
|
||||
props.event.selections.slice(0, props.visibleLimit),
|
||||
);
|
||||
|
||||
const hasMore = computed(
|
||||
() => props.event.selections.length > props.visibleLimit,
|
||||
);
|
||||
|
||||
const showLoadMore = computed(
|
||||
() => props.event.selections.length > INITIAL_BATCH && hasMore.value,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="event-block">
|
||||
<button type="button" class="event-head" :aria-expanded="expanded" @click="emit('toggle')">
|
||||
<button type="button" class="event-head" :class="{ 'is-expanded': expanded }" :aria-expanded="expanded" @click="emit('toggle')">
|
||||
<span class="toggle-icon" :class="{ open: expanded }">
|
||||
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
||||
</span>
|
||||
<span class="event-head-text">
|
||||
<span class="event-title">*{{ headTitle }}</span>
|
||||
<span class="event-title">{{ headTitle }}</span>
|
||||
<span v-if="event.leagueName && event.leagueName !== headTitle" class="event-league">
|
||||
{{ event.leagueName }}
|
||||
</span>
|
||||
@@ -79,10 +62,10 @@ const showLoadMore = computed(
|
||||
<img :src="saishiImg" alt="" class="event-saishi" />
|
||||
</button>
|
||||
|
||||
<div v-show="expanded" class="options-wrap">
|
||||
<div v-show="expanded" class="options-scroll">
|
||||
<div class="options-grid">
|
||||
<OutrightOptionCard
|
||||
v-for="sel in visibleSelections"
|
||||
v-for="sel in event.selections"
|
||||
:key="sel.id"
|
||||
:team-code="sel.teamCode"
|
||||
:team-name="sel.teamName"
|
||||
@@ -91,24 +74,6 @@ const showLoadMore = computed(
|
||||
@pick="emit('pick', sel)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showLoadMore" class="load-more-zone">
|
||||
<p class="load-more-hint">
|
||||
{{
|
||||
t('bet.outright_shown_count', {
|
||||
shown: visibleSelections.length,
|
||||
total: event.selections.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="load-more-btn"
|
||||
:disabled="loadingMore"
|
||||
@click="emit('loadMore')"
|
||||
>
|
||||
{{ loadingMore ? t('bet.loading') : t('bet.outright_load_more') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -116,6 +81,9 @@ const showLoadMore = computed(
|
||||
<style scoped>
|
||||
.event-block {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-head {
|
||||
@@ -125,8 +93,8 @@ const showLoadMore = computed(
|
||||
gap: 10px;
|
||||
padding: 12px 10px;
|
||||
background: #141414;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -192,43 +160,18 @@ const showLoadMore = computed(
|
||||
border-left: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.options-wrap {
|
||||
padding: 10px 0 4px;
|
||||
.options-scroll {
|
||||
max-height: 408px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 8px 2px 2px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #333 transparent;
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.load-more-zone {
|
||||
padding: 14px 8px 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
padding: 11px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: linear-gradient(180deg, #1f1f1f, #141414);
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.load-more-btn:disabled {
|
||||
opacity: 0.65;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { teamFlagUrl } from '../../utils/teamFlag';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -15,6 +15,9 @@ const flag = computed(
|
||||
() => props.logoUrl?.trim() || teamFlagUrl(props.teamCode, props.teamName),
|
||||
);
|
||||
const flagFailed = ref(false);
|
||||
const imgVisible = ref(false);
|
||||
const cardRef = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
function onFlagError() {
|
||||
flagFailed.value = true;
|
||||
@@ -31,16 +34,34 @@ function formatOdds(odds: string) {
|
||||
const n = parseFloat(odds);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : odds;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!cardRef.value) return;
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting) {
|
||||
imgVisible.value = true;
|
||||
observer?.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '120px' }
|
||||
);
|
||||
observer.observe(cardRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="option-card" @click="emit('pick')">
|
||||
<button ref="cardRef" type="button" class="option-card" @click="emit('pick')">
|
||||
<img
|
||||
v-if="flag && !flagFailed"
|
||||
v-if="imgVisible && flag && !flagFailed"
|
||||
:src="flag"
|
||||
alt=""
|
||||
class="flag"
|
||||
loading="lazy"
|
||||
@error="onFlagError"
|
||||
/>
|
||||
<span v-else class="flag-placeholder" aria-hidden="true">⚽</span>
|
||||
|
||||
@@ -8,19 +8,15 @@ import OutrightEventSection, {
|
||||
} from './OutrightEventSection.vue';
|
||||
import OutrightBetModal, { type OutrightPick } from './OutrightBetModal.vue';
|
||||
import emptyMatchesImg from '../../assets/images/empty-matches.svg';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const INITIAL_BATCH = 20;
|
||||
const LOAD_MORE_STEP = 28;
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref('');
|
||||
const loadingMoreId = ref<string | null>(null);
|
||||
const events = ref<OutrightEvent[]>([]);
|
||||
const expanded = ref<Set<string>>(new Set());
|
||||
const visibleLimits = ref<Record<string, number>>({});
|
||||
const modalOpen = ref(false);
|
||||
const activePick = ref<OutrightPick | null>(null);
|
||||
|
||||
@@ -29,60 +25,37 @@ const totalSelections = computed(() =>
|
||||
events.value.reduce((sum, e) => sum + e.selections.length, 0),
|
||||
);
|
||||
|
||||
function resetVisibleLimits() {
|
||||
const next: Record<string, number> = {};
|
||||
for (const e of events.value) {
|
||||
next[e.id] =
|
||||
e.selections.length <= INITIAL_BATCH
|
||||
? e.selections.length
|
||||
: INITIAL_BATCH;
|
||||
}
|
||||
visibleLimits.value = next;
|
||||
}
|
||||
|
||||
function syncExpandedAfterLoad() {
|
||||
const ids = events.value.map((e) => e.id);
|
||||
const kept = new Set([...expanded.value].filter((id) => ids.includes(id)));
|
||||
if (kept.size > 0) {
|
||||
expanded.value = kept;
|
||||
// 只保留仍然存在的 id,且最多保留 1 个
|
||||
const kept = [...expanded.value].filter((id) => ids.includes(id));
|
||||
if (kept.length > 0) {
|
||||
expanded.value = new Set([kept[0]]);
|
||||
return;
|
||||
}
|
||||
if (ids.length === 1) {
|
||||
expanded.value = new Set(ids);
|
||||
} else if (ids.length <= 3) {
|
||||
expanded.value = new Set(ids);
|
||||
} else {
|
||||
if (ids.length > 0) {
|
||||
expanded.value = new Set([ids[0]]);
|
||||
} else {
|
||||
expanded.value = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function hasMoreSelections(event: OutrightEvent) {
|
||||
const limit = visibleLimits.value[event.id] ?? INITIAL_BATCH;
|
||||
return event.selections.length > limit;
|
||||
}
|
||||
|
||||
function loadMore(event: OutrightEvent) {
|
||||
if (loadingMoreId.value || !hasMoreSelections(event)) return;
|
||||
loadingMoreId.value = event.id;
|
||||
const current = visibleLimits.value[event.id] ?? INITIAL_BATCH;
|
||||
visibleLimits.value = {
|
||||
...visibleLimits.value,
|
||||
[event.id]: Math.min(current + LOAD_MORE_STEP, event.selections.length),
|
||||
};
|
||||
loadingMoreId.value = null;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
const hadData = events.value.length > 0;
|
||||
if (!hadData) loading.value = true;
|
||||
loadError.value = '';
|
||||
try {
|
||||
const { data } = await api.get('/player/outrights');
|
||||
const list = (data?.data ?? []) as OutrightEvent[];
|
||||
events.value = list.filter((e) => e.selections?.length > 0);
|
||||
resetVisibleLimits();
|
||||
syncExpandedAfterLoad();
|
||||
const fresh = list.filter((e) => e.selections?.length > 0);
|
||||
if (!hadData) {
|
||||
events.value = fresh;
|
||||
syncExpandedAfterLoad();
|
||||
} else {
|
||||
mergeOddsOnly(fresh);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
events.value = [];
|
||||
if (!hadData) events.value = [];
|
||||
const err = e as { response?: { status?: number; data?: { error?: string } } };
|
||||
if (err.response?.status === 403) {
|
||||
loadError.value = t('bet.outright_player_only');
|
||||
@@ -90,7 +63,26 @@ async function load() {
|
||||
loadError.value = err.response?.data?.error ?? t('bet.outright_load_failed');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (!hadData) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOddsOnly(fresh: OutrightEvent[]) {
|
||||
const freshMap = new Map<string, OutrightEvent>();
|
||||
for (const e of fresh) freshMap.set(e.id, e);
|
||||
|
||||
for (const event of events.value) {
|
||||
const freshEvent = freshMap.get(event.id);
|
||||
if (!freshEvent) continue;
|
||||
const selMap = new Map<string, OutrightSelection>();
|
||||
for (const s of freshEvent.selections) selMap.set(s.id, s);
|
||||
for (const sel of event.selections) {
|
||||
const fs = selMap.get(sel.id);
|
||||
if (fs) {
|
||||
sel.odds = fs.odds;
|
||||
sel.oddsVersion = fs.oddsVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,18 +93,6 @@ function toggle(id: string) {
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
expanded.value = next;
|
||||
if (next.has(id) && visibleLimits.value[id] == null) {
|
||||
const event = events.value.find((e) => e.id === id);
|
||||
if (event) {
|
||||
visibleLimits.value = {
|
||||
...visibleLimits.value,
|
||||
[id]:
|
||||
event.selections.length <= INITIAL_BATCH
|
||||
? event.selections.length
|
||||
: INITIAL_BATCH,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openBet(event: OutrightEvent, sel: OutrightSelection) {
|
||||
@@ -135,8 +115,9 @@ function closeModal() {
|
||||
|
||||
<template>
|
||||
<div class="outright-panel">
|
||||
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
|
||||
|
||||
<div v-if="loading" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
<template v-else-if="events.length">
|
||||
<p v-if="eventCount > 1" class="panel-summary">
|
||||
{{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }}
|
||||
@@ -148,10 +129,7 @@ function closeModal() {
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
:expanded="expanded.has(event.id)"
|
||||
:visible-limit="visibleLimits[event.id] ?? INITIAL_BATCH"
|
||||
:loading-more="loadingMoreId === event.id"
|
||||
@toggle="toggle(event.id)"
|
||||
@load-more="loadMore(event)"
|
||||
@pick="openBet(event, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user