feat(admin,player,api): 优胜冠军通用管理与界面精简

管理端新增冠军盘列表/编辑、展开懒加载与 ECharts 修复;各列表页去掉重复标题。玩家端支持多赛事冠军盘、分批加载与语言切换刷新。API 扩展 outright CRUD 与列表性能优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 09:17:01 +08:00
parent 9b63d67e7c
commit 27580b2479
39 changed files with 2250 additions and 578 deletions

View File

@@ -1,56 +1,118 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import OutrightOptionCard from './OutrightOptionCard.vue';
import OutrightEventSection, {
type OutrightEvent,
type OutrightSelection,
} from './OutrightEventSection.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[];
}
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);
const eventCount = computed(() => events.value.length);
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;
return;
}
if (ids.length === 1) {
expanded.value = new Set(ids);
} else if (ids.length <= 3) {
expanded.value = new Set(ids);
} else {
expanded.value = new Set([ids[0]]);
}
}
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;
loadError.value = '';
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]);
const list = (data?.data ?? []) as OutrightEvent[];
events.value = list.filter((e) => e.selections?.length > 0);
resetVisibleLimits();
syncExpandedAfterLoad();
} catch (e: unknown) {
events.value = [];
const err = e as { response?: { status?: number; data?: { error?: string } } };
if (err.response?.status === 403) {
loadError.value = t('bet.outright_player_only');
} else {
loadError.value = err.response?.data?.error ?? t('bet.outright_load_failed');
}
} finally {
loading.value = false;
}
}
onMounted(load);
useOnLocaleChange(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;
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) {
@@ -75,32 +137,31 @@ function closeModal() {
<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>
<template v-else-if="events.length">
<p v-if="eventCount > 1" class="panel-summary">
{{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }}
</p>
<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 class="event-list">
<OutrightEventSection
v-for="event in events"
: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>
</template>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_outright') }}</p>
<p v-if="loadError">{{ loadError }}</p>
<p v-else>{{ t('bet.no_outright') }}</p>
<p v-if="!loadError" class="empty-hint">{{ t('bet.no_outright_hint') }}</p>
</div>
<OutrightBetModal :open="modalOpen" :pick="activePick" @close="closeModal" />
@@ -112,64 +173,17 @@ function closeModal() {
padding: 4px 12px 0;
}
.event-block {
margin-bottom: 10px;
.panel-summary {
margin: 0 0 12px;
padding: 0 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
line-height: 1.4;
}
.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;
.event-list {
padding-bottom: 8px;
}
.state,
@@ -185,4 +199,11 @@ function closeModal() {
height: 96px;
margin-bottom: 14px;
}
.empty-hint {
margin-top: 8px;
font-size: 12px;
font-weight: 500;
opacity: 0.85;
}
</style>