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

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import OutrightOptionCard from './OutrightOptionCard.vue';
import saishiImg from '../../assets/images/saishi.png';
export interface OutrightSelection {
id: string;
teamCode: string;
teamName: string;
odds: string;
oddsVersion: string;
}
export interface OutrightEvent {
id: string;
leagueId: string;
leagueCode?: string;
leagueName: string;
title: string;
selectionCount?: number;
selections: OutrightSelection[];
}
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');
});
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')">
<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 v-if="event.leagueName && event.leagueName !== headTitle" class="event-league">
{{ event.leagueName }}
</span>
<span class="event-meta">{{ headMeta }}</span>
</span>
<img :src="saishiImg" alt="" class="event-saishi" />
</button>
<div v-show="expanded" class="options-wrap">
<div class="options-grid">
<OutrightOptionCard
v-for="sel in visibleSelections"
:key="sel.id"
:team-code="sel.teamCode"
:team-name="sel.teamName"
:odds="sel.odds"
@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>
<style scoped>
.event-block {
margin-bottom: 10px;
}
.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;
transition: border-color 0.15s;
}
.toggle-icon.open {
border-color: var(--primary-light);
}
.toggle-mark {
color: var(--primary-light);
font-size: 17px;
font-weight: 900;
line-height: 1;
}
.event-head-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.event-title {
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
line-height: 1.35;
}
.event-league {
font-size: 11px;
font-weight: 600;
color: #888;
line-height: 1.3;
}
.event-meta {
font-size: 11px;
font-weight: 600;
color: #666;
}
.event-saishi {
flex-shrink: 0;
height: 44px;
width: auto;
max-width: 40px;
object-fit: contain;
padding-left: 8px;
border-left: 1px solid #2a2a2a;
}
.options-wrap {
padding: 10px 0 4px;
}
.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;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { teamFlagUrl } from '../../utils/teamFlag';
const props = defineProps<{
@@ -11,6 +11,18 @@ const props = defineProps<{
const emit = defineEmits<{ pick: [] }>();
const flag = computed(() => teamFlagUrl(props.teamCode, props.teamName));
const flagFailed = ref(false);
function onFlagError() {
flagFailed.value = true;
}
watch(
() => [props.teamCode, props.teamName] as const,
() => {
flagFailed.value = false;
},
);
function formatOdds(odds: string) {
const n = parseFloat(odds);
@@ -20,7 +32,15 @@ function formatOdds(odds: string) {
<template>
<button type="button" class="option-card" @click="emit('pick')">
<img v-if="flag" :src="flag" alt="" class="flag" />
<img
v-if="flag && !flagFailed"
:src="flag"
alt=""
class="flag"
loading="lazy"
@error="onFlagError"
/>
<span v-else class="flag-placeholder" aria-hidden="true"></span>
<span class="name">{{ teamName }}</span>
<span class="odds">[ {{ formatOdds(odds) }} ]</span>
</button>
@@ -53,6 +73,16 @@ function formatOdds(odds: string) {
border-radius: 2px;
}
.flag-placeholder {
width: 28px;
height: 19px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
opacity: 0.55;
}
.name {
font-size: 11px;
font-weight: 800;

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>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
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 { useOnLocaleChange } from '../../composables/useOnLocaleChange';
type TimeFilter = 'all' | 'today';
@@ -43,7 +44,7 @@ const timeFilter = ref<TimeFilter>('all');
const parlayMarketKeys = PARLAY_MARKET_TYPES.map((c) => c.key);
onMounted(async () => {
async function loadParlayMatches() {
loading.value = true;
try {
const { data } = await api.get('/player/matches');
@@ -53,7 +54,9 @@ onMounted(async () => {
} finally {
loading.value = false;
}
});
}
useOnLocaleChange(loadParlayMatches);
function parseLine(v: string | number | null | undefined) {
if (v == null || v === '') return null;

View File

@@ -27,7 +27,9 @@ export function useAppLocale() {
}
async function setLocale(code: string) {
applyLocale(code);
if (!(SUPPORTED_LOCALES as readonly string[]).includes(code)) return;
if (locale.value === code) return;
if (auth.token) {
try {
await api.post('/player/language', { locale: code });
@@ -39,6 +41,7 @@ export function useAppLocale() {
/* 离线或 token 过期时仍保留本地语言 */
}
}
applyLocale(code);
}
function initFromUser(userLocale?: string | null) {

View File

@@ -0,0 +1,15 @@
import { onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
/** 挂载时加载一次;语言切换后重新拉取服务端本地化数据 */
export function useOnLocaleChange(loader: () => void | Promise<void>) {
const { locale } = useI18n();
onMounted(() => {
void loader();
});
watch(locale, (next, prev) => {
if (prev && next !== prev) void loader();
});
}

View File

@@ -10,10 +10,10 @@ import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import { useAppLocale } from '../composables/useAppLocale';
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
import BottomNavIcon from '../components/BottomNavIcon.vue';
import { computed, onMounted } from 'vue';
import { computed, onMounted, watch } from 'vue';
import { useAnnouncements } from '../composables/useAnnouncements';
const { t } = useI18n();
const { t, locale } = useI18n();
const auth = useAuthStore();
const { initFromUser } = useAppLocale();
const route = useRoute();
@@ -26,6 +26,10 @@ onMounted(() => {
loadAnnouncements();
if (auth.user?.locale) initFromUser(auth.user.locale);
});
watch(locale, (next, prev) => {
if (prev && next !== prev) void loadAnnouncements();
});
</script>
<template>

View File

@@ -79,6 +79,13 @@ const i18n = createI18n({
stake_max: '全部',
placing: '提交中…',
no_outright: '暂无冠军盘口',
no_outright_hint: '请使用玩家账号登录;若仍无数据,请联系管理员在后台发布优胜冠军赛事',
outright_events_summary: '共 {events} 个冠军赛事 · {teams} 支队伍',
outright_teams_count: '{n} 支队伍',
outright_load_failed: '冠军盘加载失败,请检查网络或稍后重试',
outright_player_only: '请使用玩家账号登录后查看',
outright_shown_count: '已显示 {shown} / {total} 队',
outright_load_more: '加载更多',
cancel: '取消',
parlay_title: '串关投注',
parlay_guide_title: '串关怎么投?',
@@ -263,6 +270,13 @@ const i18n = createI18n({
stake_max: 'Max',
placing: 'Placing…',
no_outright: 'No outright markets',
no_outright_hint: 'Sign in as a player. If empty, ask admin to publish outright events.',
outright_events_summary: '{events} outright events · {teams} teams',
outright_teams_count: '{n} teams',
outright_load_failed: 'Failed to load outright markets',
outright_player_only: 'Player login required',
outright_shown_count: '{shown} / {total} teams shown',
outright_load_more: 'Load more',
cancel: 'Cancel',
parlay_title: 'Parlay',
parlay_guide_title: 'How to parlay',
@@ -453,6 +467,13 @@ const i18n = createI18n({
stake_max: 'Maks',
placing: 'Memproses…',
no_outright: 'Tiada pasaran juara',
no_outright_hint: 'Log masuk sebagai pemain. Jika kosong, minta admin terbitkan acara juara.',
outright_events_summary: '{events} acara juara · {teams} pasukan',
outright_teams_count: '{n} pasukan',
outright_load_failed: 'Gagal memuatkan pasaran juara',
outright_player_only: 'Log masuk pemain diperlukan',
outright_shown_count: '{shown} / {total} pasukan dipaparkan',
outright_load_more: 'Muat lagi',
cancel: 'Batal',
parlay_title: 'Pertaruhan Berganda',
parlay_guide_title: 'Cara parlay',

View File

@@ -1,82 +1,162 @@
/** 球队 code / 名称 → ISO 3166-1 alpha-2用于 flagcdn 国旗图 */
/** 球队 code / 名称 → ISO 3166-1flagcdn.com含世界杯 48 强 */
const CODE_TO_ISO: Record<string, string> = {
MEX: 'mx',
USA: 'us',
CAN: 'ca',
BRA: 'br',
ARG: 'ar',
ENG: 'gb',
MUN: 'gb',
CHE: 'gb',
LIV: 'gb',
MCI: 'gb',
CZE: 'cz',
KOR: 'kr',
BIH: 'ba',
PAR: 'py',
RSA: 'za',
SUI: 'ch',
SCO: 'gb-sct',
TUR: 'tr',
CZE: 'cz',
BIH: 'ba',
// 世界杯 2026 48 强
FRA: 'fr',
ESP: 'es',
ENG: 'gb',
GER: 'de',
ENG: 'gb-eng',
BRA: 'br',
ARG: 'ar',
POR: 'pt',
GER: 'de',
NED: 'nl',
NOR: 'no',
BEL: 'be',
COL: 'co',
JPN: 'jp',
URU: 'uy',
USA: 'us',
MAR: 'ma',
CRO: 'hr',
MEX: 'mx',
SUI: 'ch',
TUR: 'tr',
SEN: 'sn',
KOR: 'kr',
AUT: 'at',
ECU: 'ec',
SWE: 'se',
IRN: 'ir',
GHA: 'gh',
ALG: 'dz',
BIH: 'ba',
EGY: 'eg',
TUN: 'tn',
CAN: 'ca',
PAN: 'pa',
AUS: 'au',
CZE: 'cz',
KSA: 'sa',
NZL: 'nz',
COD: 'cd',
UZB: 'uz',
IRQ: 'iq',
RSA: 'za',
CIV: 'ci',
JOR: 'jo',
PAR: 'py',
HAI: 'ht',
QAT: 'qa',
CPV: 'cv',
CUW: 'cw',
SCO: 'gb-sct',
// 俱乐部 / 演示联赛
MUN: 'gb',
CHE: 'gb',
LIV: 'gb',
MCI: 'gb',
};
const NAME_TO_ISO: Record<string, string> = {
Mexico: 'mx',
'South Africa': 'za',
'United States': 'us',
USA: 'us',
Canada: 'ca',
France: 'fr',
Spain: 'es',
England: 'gb-eng',
Brazil: 'br',
Argentina: 'ar',
Portugal: 'pt',
Germany: 'de',
Netherlands: 'nl',
Norway: 'no',
Belgium: 'be',
Colombia: 'co',
Japan: 'jp',
Uruguay: 'uy',
USA: 'us',
'United States': 'us',
Morocco: 'ma',
Croatia: 'hr',
Mexico: 'mx',
Switzerland: 'ch',
Scotland: 'gb-sct',
Turkey: 'tr',
Senegal: 'sn',
'South Korea': 'kr',
Austria: 'at',
Ecuador: 'ec',
Sweden: 'se',
Iran: 'ir',
Ghana: 'gh',
Algeria: 'dz',
Bosnia: 'ba',
Egypt: 'eg',
Tunisia: 'tn',
Canada: 'ca',
Panama: 'pa',
Australia: 'au',
Czech: 'cz',
'Czech Republic': 'cz',
'Saudi Arabia': 'sa',
'New Zealand': 'nz',
'DR Congo': 'cd',
Uzbekistan: 'uz',
Iraq: 'iq',
'South Africa': 'za',
'Ivory Coast': 'ci',
Jordan: 'jo',
Paraguay: 'py',
西: 'mx',
: 'us',
: 'ca',
: 'gb',
西: 'gb',
西: 'mx',
: 'za',
: 'cz',
: 'kr',
: 'ba',
: 'py',
: 'ch',
西: 'br',
: 'gb-sct',
: 'tr',
: 'fr',
: 'ar',
Haiti: 'ht',
Qatar: 'qa',
'Cape Verde': 'cv',
Curacao: 'cw',
Scotland: 'gb-sct',
: 'fr',
西: 'es',
: 'gb',
: 'de',
: 'gb-eng',
西: 'br',
: 'ar',
: 'pt',
: 'de',
: 'nl',
: 'no',
: 'be',
: 'co',
: 'jp',
: 'uy',
: 'us',
: 'ma',
: 'hr',
西: 'mx',
: 'ch',
: 'tr',
: 'sn',
: 'kr',
: 'at',
: 'ec',
: 'se',
: 'ir',
: 'gh',
: 'dz',
: 'ba',
: 'eg',
: 'tn',
: 'ca',
: 'pa',
: 'au',
: 'cz',
: 'sa',
西: 'nz',
'刚果(金)': 'cd',
: 'uz',
: 'iq',
: 'za',
: 'ci',
: 'jo',
: 'py',
: 'ht',
: 'qa',
: 'cv',
: 'cw',
: 'gb-sct',
: 'gb',
西: 'gb',
};
export function teamFlagUrl(code?: string, name?: string): string {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
@@ -8,6 +8,7 @@ import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
import OutrightPanel from '../components/outright/OutrightPanel.vue';
import ParlayPanel from '../components/parlay/ParlayPanel.vue';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
type MainTab = 'matches' | 'outright' | 'parlay';
type TimeTab = 'today' | 'early';
@@ -39,7 +40,7 @@ const matches = ref<Match[]>([]);
const loading = ref(true);
const expandedLeagues = ref<Set<string>>(new Set());
onMounted(async () => {
async function loadMatches() {
loading.value = true;
try {
const { data } = await api.get('/player/matches');
@@ -47,7 +48,9 @@ onMounted(async () => {
} finally {
loading.value = false;
}
});
}
useOnLocaleChange(loadMatches);
function dayStart(d: Date) {
const x = new Date(d);
@@ -186,7 +189,9 @@ function goMatch(id: string) {
</div>
</template>
<OutrightPanel v-else-if="mainTab === 'outright'" />
<div v-else-if="mainTab === 'outright'" class="outright-tab">
<OutrightPanel />
</div>
<ParlayPanel v-else-if="mainTab === 'parlay'" />
</div>
@@ -290,4 +295,8 @@ function goMatch(id: string) {
.parlay-tab.tab-gold-active {
flex: 1.15;
}
.outright-tab {
min-height: 0;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -8,6 +8,7 @@ import api from '../api';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
import BannerCarousel from '../components/BannerCarousel.vue';
import { resolveBanners } from '../constants/defaultBanner';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
const router = useRouter();
const home = ref<{
@@ -38,10 +39,12 @@ interface Match {
const displayBanners = computed(() => resolveBanners(home.value?.banners));
onMounted(async () => {
async function loadHome() {
const { data } = await api.get('/player/home');
home.value = data.data;
});
}
useOnLocaleChange(loadHome);
function goMatch(id: string) {
router.push(`/match/${id}`);

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
@@ -14,6 +14,7 @@ import CorrectScoreConfirmModal, {
type CsConfirmLine,
} from '../components/match-detail/CorrectScoreConfirmModal.vue';
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
const route = useRoute();
const router = useRouter();
@@ -197,7 +198,7 @@ async function loadMatch() {
}
}
onMounted(loadMatch);
useOnLocaleChange(loadMatch);
function isSelected(id: string) {
return slip.items.some((i) => i.selectionId === id);

View File

@@ -1,16 +1,15 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../api';
import BetHistoryCard, { type BetHistoryItem } from '../components/BetHistoryCard.vue';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
const { t } = useI18n();
const bets = ref<{ items: BetHistoryItem[]; total: number }>({ items: [], total: 0 });
const loading = ref(true);
onMounted(load);
async function load() {
loading.value = true;
try {
@@ -20,6 +19,8 @@ async function load() {
loading.value = false;
}
}
useOnLocaleChange(load);
</script>
<template>