feat(admin,player,api): 优胜冠军通用管理与界面精简
管理端新增冠军盘列表/编辑、展开懒加载与 ECharts 修复;各列表页去掉重复标题。玩家端支持多赛事冠军盘、分批加载与语言切换刷新。API 扩展 outright CRUD 与列表性能优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
232
apps/player/src/components/outright/OutrightEventSection.vue
Normal file
232
apps/player/src/components/outright/OutrightEventSection.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
15
apps/player/src/composables/useOnLocaleChange.ts
Normal file
15
apps/player/src/composables/useOnLocaleChange.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,82 +1,162 @@
|
||||
/** 球队 code / 名称 → ISO 3166-1 alpha-2,用于 flagcdn 国旗图 */
|
||||
/** 球队 code / 名称 → ISO 3166-1(flagcdn.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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user