feat(admin,api,player): 优胜赛配置、赛事管理重构与玩家端投注体验优化
管理端拆分赛事/优胜赛 Tab,新增联赛优胜赔率面板(批量、排序、外侧删除);统一 list-chrome 工具栏对齐与列表页布局;Dashboard 失败重试、Users 操作下拉、小屏侧栏等体验修复。 API 扩展优胜赛与赛事目录接口,完善投注与钱包查询;玩家端重构赛事卡片、串关面板、注单/钱包页,新增注单详情、下注成功动画与下拉刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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">⚽</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,
|
||||
|
||||
Reference in New Issue
Block a user