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

@@ -6,6 +6,8 @@ import { useBetSlipStore } from '../../stores/betSlip';
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS } from '../../utils/parlayColumns';
import BetGuideHelp from '../BetGuideHelp.vue';
import GoldSpinner from '../GoldSpinner.vue';
import TeamEmblem from '../TeamEmblem.vue';
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
type TimeFilter = 'all' | 'today';
@@ -29,8 +31,13 @@ interface Market {
interface ParlayMatch {
id: string;
leagueName: string;
leagueId?: string;
homeTeamName: string;
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamLogoUrl?: string | null;
awayTeamLogoUrl?: string | null;
startTime: string;
markets: Market[];
}
@@ -41,23 +48,87 @@ const slip = useBetSlipStore();
const loading = ref(true);
const matches = ref<ParlayMatch[]>([]);
const timeFilter = ref<TimeFilter>('all');
const leagueFilter = ref('');
const collapsed = ref<Set<string>>(new Set());
const parlayMarketKeys = PARLAY_MARKET_TYPES.map((c) => c.key);
async function loadParlayMatches() {
loading.value = true;
const hadData = matches.value.length > 0;
if (!hadData) loading.value = true;
try {
const { data } = await api.get('/player/matches');
matches.value = (data.data ?? []).filter(
const fresh = (data.data ?? []).filter(
(m: ParlayMatch) => m.markets?.length && hasParlayMarkets(m),
);
if (!hadData) {
matches.value = fresh;
syncCollapsedAfterLoad();
} else {
mergeOddsOnly(fresh);
}
} finally {
loading.value = false;
if (!hadData) loading.value = false;
}
}
function syncCollapsedAfterLoad() {
const ids = matches.value.map((m) => m.id);
// 只保留仍然存在的 id
const kept = [...collapsed.value].filter((id) => ids.includes(id));
if (kept.length > 0) {
collapsed.value = new Set(kept);
return;
}
// 默认只展开第一个,其余折叠
if (ids.length > 1) {
collapsed.value = new Set(ids.slice(1));
} else {
collapsed.value = new Set();
}
}
function mergeOddsOnly(fresh: ParlayMatch[]) {
const matchMap = new Map<string, ParlayMatch>();
for (const m of fresh) matchMap.set(m.id, m);
for (const match of matches.value) {
const freshMatch = matchMap.get(match.id);
if (!freshMatch) continue;
const marketMap = new Map<string, Market>();
for (const mk of freshMatch.markets) marketMap.set(mk.id, mk);
for (const market of match.markets) {
const freshMarket = marketMap.get(market.id);
if (!freshMarket) continue;
const selMap = new Map<string, Selection>();
for (const s of freshMarket.selections) selMap.set(s.id, s);
for (const sel of market.selections) {
const fs = selMap.get(sel.id);
if (fs) {
sel.odds = fs.odds;
sel.oddsVersion = fs.oddsVersion;
}
}
}
}
}
useOnLocaleChange(loadParlayMatches);
const leagues = computed(() => {
const seen = new Set<string>();
const list: { id: string; name: string }[] = [];
for (const m of matches.value) {
const id = m.leagueId ?? m.leagueName;
if (!seen.has(id)) {
seen.add(id);
list.push({ id, name: m.leagueName });
}
}
list.sort((a, b) => a.name.localeCompare(b.name));
return list;
});
function parseLine(v: string | number | null | undefined) {
if (v == null || v === '') return null;
const n = typeof v === 'number' ? v : parseFloat(String(v));
@@ -100,8 +171,14 @@ function isKickoffToday(startTime: string) {
}
const filteredMatches = computed(() => {
if (timeFilter.value === 'all') return matches.value;
return matches.value.filter((m) => isKickoffToday(m.startTime));
let list = matches.value;
if (timeFilter.value === 'today') {
list = list.filter((m) => isKickoffToday(m.startTime));
}
if (leagueFilter.value) {
list = list.filter((m) => (m.leagueId ?? m.leagueName) === leagueFilter.value);
}
return list;
});
function formatKickoff(startTime: string) {
@@ -163,76 +240,101 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
const footConfirmLabel = computed(() =>
slip.canPlaceParlay ? t('bet.parlay_confirm_parlay') : t('bet.cs_confirm_cell'),
);
function toggleCollapse(id: string) {
const next = new Set(collapsed.value);
if (next.has(id)) next.delete(id);
else next.add(id);
collapsed.value = next;
}
</script>
<template>
<div class="parlay-panel" :class="{ 'has-fixed-foot': showParlayFoot }">
<div class="toolbar">
<div class="toolbar-filters">
<select v-model="timeFilter" class="filter-select">
<select v-model="timeFilter" class="filter-select">
<option value="all">{{ t('bet.parlay_filter_all') }}</option>
<option value="today">{{ t('bet.tab_today') }}</option>
</select>
<BetGuideHelp
:title="t('bet.parlay_guide_title')"
:aria-label="t('bet.parlay_guide_help')"
storage-key="thebet365_parlay_guide_seen"
>
<p class="intro">{{ t('bet.parlay_desc') }}</p>
<ol>
<li>{{ t('bet.parlay_guide_1') }}</li>
<li>{{ t('bet.parlay_guide_2') }}</li>
<li>{{ t('bet.parlay_guide_3') }}</li>
</ol>
<p class="rules-link">{{ t('bet.guide_rules_link') }}</p>
</BetGuideHelp>
</div>
<div class="col-headers">
<span v-for="col in PARLAY_MARKET_TYPES" :key="col.key" class="col-head">{{ colLabel(col.labelKey) }}</span>
</div>
</select>
<select v-model="leagueFilter" class="filter-select">
<option value="">{{ t('bet.parlay_filter_all') }}</option>
<option v-for="lg in leagues" :key="lg.id" :value="lg.id">{{ lg.name }}</option>
</select>
<BetGuideHelp
:title="t('bet.parlay_guide_title')"
:aria-label="t('bet.parlay_guide_help')"
storage-key="thebet365_parlay_guide_seen"
>
<p class="intro">{{ t('bet.parlay_desc') }}</p>
<ol>
<li>{{ t('bet.parlay_guide_1') }}</li>
<li>{{ t('bet.parlay_guide_2') }}</li>
<li>{{ t('bet.parlay_guide_3') }}</li>
</ol>
<p class="rules-link">{{ t('bet.guide_rules_link') }}</p>
</BetGuideHelp>
</div>
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="filteredMatches.length" class="table-wrap">
<div v-for="match in filteredMatches" :key="match.id" class="match-row">
<div class="match-info">
<div class="league">{{ match.leagueName }}</div>
<div class="teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
<div class="time">{{ formatKickoff(match.startTime) }}</div>
</div>
<div class="odds-cells">
<div v-else-if="filteredMatches.length" class="match-list">
<div v-for="match in filteredMatches" :key="match.id" class="match-card" :class="{ collapsed: collapsed.has(match.id) }">
<button type="button" class="match-head" @click="toggleCollapse(match.id)">
<span class="m-league">{{ match.leagueName }}</span>
<TeamEmblem
size="sm"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="m-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</span>
<TeamEmblem
size="sm"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="m-time">{{ formatKickoff(match.startTime) }}</span>
<span class="toggle-dot" :class="{ open: !collapsed.has(match.id) }">{{ collapsed.has(match.id) ? '+' : '' }}</span>
</button>
<div v-show="!collapsed.has(match.id)" class="market-blocks">
<div
v-for="col in PARLAY_MARKET_TYPES"
:key="col.key"
class="market-cell"
class="market-block"
>
<template
v-if="
getMarket(match, col.key) &&
isParlayEligibleMarket(getMarket(match, col.key)!)
"
>
<button
v-for="sel in getMarket(match, col.key)!.selections"
:key="sel.id"
type="button"
class="odd-btn"
:class="{ picked: isPicked(sel.id) }"
@click="pickSelection(match, getMarket(match, col.key)!, sel)"
<span class="block-label">{{ colLabel(col.labelKey) }}</span>
<div class="block-btns">
<template
v-if="
getMarket(match, col.key) &&
isParlayEligibleMarket(getMarket(match, col.key)!)
"
>
<span class="odd-label">{{ selLabel(sel) }}</span>
<span class="odd-val">{{ formatOdds(sel.odds) }}</span>
</button>
</template>
<span v-else class="market-empty">—</span>
<button
v-for="sel in getMarket(match, col.key)!.selections"
:key="sel.id"
type="button"
class="odd-btn"
:class="{ picked: isPicked(sel.id) }"
@click="pickSelection(match, getMarket(match, col.key)!, sel)"
>
<span class="odd-label">{{ selLabel(sel) }}</span>
<span class="odd-val">{{ formatOdds(sel.odds) }}</span>
</button>
</template>
<span v-else class="market-empty">—</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty">
<span class="empty-icon" aria-hidden="true">📅</span>
<span class="empty-icon" aria-hidden="true">&#9917;</span>
<p>{{ t('bet.parlay_empty') }}</p>
</div>
@@ -267,11 +369,23 @@ const footConfirmLabel = computed(() =>
padding-bottom: 100px;
}
.toolbar-filters {
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-bottom: 8px;
flex-wrap: wrap;
}
.filter-select {
padding: 7px 10px;
border-radius: 6px;
background: #141414;
border: 1px solid var(--border-gold-soft);
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
max-width: 140px;
}
.parlay-foot-fixed {
@@ -297,12 +411,6 @@ const footConfirmLabel = computed(() =>
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;
@@ -324,102 +432,118 @@ const footConfirmLabel = computed(() =>
opacity: 0.45;
}
.toolbar {
display: flex;
align-items: stretch;
gap: 8px;
margin-bottom: 8px;
overflow-x: auto;
}
.filter-select {
flex-shrink: 0;
min-width: 72px;
padding: 8px 10px;
border-radius: 6px;
background: #141414;
border: 1px solid var(--border-gold-soft);
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
}
.col-headers {
display: grid;
grid-template-columns: repeat(7, minmax(52px, 1fr));
gap: 4px;
flex: 1;
min-width: 360px;
align-items: center;
}
.col-head {
font-size: 10px;
font-weight: 800;
color: var(--text-muted);
text-align: center;
line-height: 1.25;
word-break: keep-all;
}
.table-wrap {
.match-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.match-row {
display: flex;
gap: 8px;
padding: 10px 8px;
.match-card {
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 6px;
overflow-x: auto;
border-radius: 8px;
overflow: hidden;
}
.match-info {
.match-card.collapsed {
border-color: #222;
}
.match-head {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
padding: 10px;
background: none;
border: none;
color: inherit;
font-family: inherit;
cursor: pointer;
}
.match-head:active {
background: rgba(255, 255, 255, 0.015);
}
.toggle-dot {
flex-shrink: 0;
width: 88px;
min-width: 88px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #1a1a1a;
border: 1px solid #333;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 900;
color: #666;
line-height: 1;
transition: all 0.15s;
}
.league {
font-size: 9px;
.toggle-dot.open {
border-color: var(--border-gold-soft);
color: var(--primary-light);
}
.m-league {
font-size: 10px;
color: var(--text-muted);
margin-bottom: 2px;
font-weight: 600;
flex-shrink: 0;
}
.m-teams {
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.teams {
font-size: 11px;
font-weight: 800;
color: var(--primary-light);
line-height: 1.25;
margin-bottom: 4px;
}
.time {
font-size: 9px;
.m-time {
font-size: 10px;
color: var(--text-muted);
flex-shrink: 0;
}
.odds-cells {
display: grid;
grid-template-columns: repeat(7, minmax(52px, 1fr));
gap: 4px;
flex: 1;
min-width: 360px;
align-items: stretch;
.match-head :deep(.team-emblem) {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.market-cell {
.market-blocks {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 10px 10px;
}
.market-block {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.block-label {
font-size: 9.5px;
font-weight: 800;
color: var(--text-muted);
letter-spacing: 0.03em;
padding: 0 2px;
}
.block-btns {
display: flex;
gap: 3px;
min-height: 36px;
flex-wrap: wrap;
}
.odd-btn {
@@ -428,12 +552,12 @@ const footConfirmLabel = computed(() =>
align-items: center;
justify-content: center;
gap: 1px;
padding: 4px 2px;
padding: 5px 8px;
border-radius: 4px;
background: #0d0d0d;
border: 1px solid #333;
min-height: 32px;
width: 100%;
min-width: 44px;
min-height: 36px;
}
.odd-btn.picked {
@@ -442,7 +566,7 @@ const footConfirmLabel = computed(() =>
}
.odd-label {
font-size: 9px;
font-size: 8.5px;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
@@ -456,13 +580,13 @@ const footConfirmLabel = computed(() =>
}
.market-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 36px;
color: #444;
font-size: 12px;
min-height: 32px;
}
.state,