feat(player): 新增桌面端 UI 与响应式双端路由架构
- 将原移动端页面重命名为 Mobile*,新增 22 个 Desktop* 视图与 DesktopShell 布局体系 - 路由入口改为 Wrapper 视图,通过 useViewport(≥1024px)自动切换移动/桌面端 - 新增投注单面板、盘口弹层、联赛侧栏、数据表格等桌面组件及独立样式令牌 - 扩展 betSlip store、串关筛选、冠军盘、Toast/悬浮信箱等交互与三语 i18n - 公告/消息 Hub 拆分移动与桌面实现;API 投注/钱包/充值记录支持 pageSize 分页
This commit is contained in:
47
apps/player/src/components/AppToast.vue
Normal file
47
apps/player/src/components/AppToast.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppToast } from '../composables/useAppToast';
|
||||
|
||||
const { visible, message } = useAppToast();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="app-toast">
|
||||
<div v-if="visible" class="app-toast" role="status" aria-live="polite">
|
||||
{{ message }}
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
top: calc(var(--desktop-header-h, 56px) + 16px);
|
||||
left: 50%;
|
||||
z-index: 5000;
|
||||
transform: translateX(-50%);
|
||||
max-width: min(90vw, 360px);
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(22, 22, 22, 0.96);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-toast-enter-active,
|
||||
.app-toast-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.app-toast-enter-from,
|
||||
.app-toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -8px);
|
||||
}
|
||||
</style>
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { formatMoney, parseAmount } from '../utils/localeDisplay';
|
||||
import BetSuccessOverlay from './BetSuccessOverlay.vue';
|
||||
import ConfirmDialog from './ConfirmDialog.vue';
|
||||
import api from '../api';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
import { buildBetPlaceConfirmMessage } from '../utils/betPlaceConfirmMessage';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
|
||||
@@ -33,6 +35,8 @@ const balance = ref<number | null>(null);
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
const showSuccess = ref(false);
|
||||
const showPlaceConfirm = ref(false);
|
||||
const placeConfirmMessage = ref('');
|
||||
const MIN_STAKE = 5;
|
||||
const MAX_STAKE_INTEGER_LENGTH = 9;
|
||||
const stakeInput = ref('');
|
||||
@@ -345,34 +349,54 @@ function oddsTrendClass(delta: OddsDelta) {
|
||||
return delta.newOdds >= delta.oldOdds ? 'odds-up' : 'odds-down';
|
||||
}
|
||||
|
||||
async function placeBet() {
|
||||
if (!activeItems.value.length) return;
|
||||
function validatePlaceBet(): boolean {
|
||||
if (!activeItems.value.length) return false;
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (slip.stake < MIN_STAKE) {
|
||||
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (balance.value != null && slip.stake > balance.value) {
|
||||
error.value = t('bet.outright_insufficient');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (activeTab.value === 'parlay' && !canSubmitActive.value) {
|
||||
error.value = slip.parlayItems.length > PARLAY_MAX_LEGS
|
||||
? t('bet.parlay_max_legs')
|
||||
: t('bet.parlay_need_more');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (hasSuspendedSelections.value) {
|
||||
error.value = t('bet.odds_suspended');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function onPlaceBetClick() {
|
||||
error.value = '';
|
||||
if (!validatePlaceBet()) return;
|
||||
if (hasPendingOddsChanges.value) {
|
||||
acceptPendingOdds();
|
||||
}
|
||||
const items = activeTab.value === 'parlay' ? [...slip.parlayItems] : activeItems.value;
|
||||
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
|
||||
mode: activeTab.value,
|
||||
items,
|
||||
totalStake: Number(slip.stake) || 0,
|
||||
totalReturn: activeEstimatedReturn.value,
|
||||
totalOdds: activeTab.value === 'parlay' ? activeTotalOdds.value : undefined,
|
||||
formatMoney: (amount) => formatMoney(amount, locale.value),
|
||||
getStake: () => Number(slip.stake) || 0,
|
||||
getOdds: (item) => effectiveOdds(item),
|
||||
});
|
||||
showPlaceConfirm.value = true;
|
||||
}
|
||||
|
||||
async function executePlaceBet() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
@@ -414,6 +438,11 @@ async function placeBet() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPlaceBet() {
|
||||
showPlaceConfirm.value = false;
|
||||
await executePlaceBet();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
@@ -622,7 +651,7 @@ watch(
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="loading || !canSubmitWithOdds"
|
||||
@click="placeBet"
|
||||
@click="onPlaceBetClick"
|
||||
>
|
||||
{{ submitButtonLabel }}
|
||||
</button>
|
||||
@@ -630,6 +659,16 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="showPlaceConfirm"
|
||||
:title="t('bet.place_confirm_title')"
|
||||
:message="placeConfirmMessage"
|
||||
:confirm-text="t('bet.place_bet')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:loading="loading"
|
||||
@confirm="confirmPlaceBet"
|
||||
/>
|
||||
|
||||
<BetSuccessOverlay :show="showSuccess" @done="onSuccessDone" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ onUnmounted(() => {
|
||||
</span>
|
||||
<span class="chevron" :class="{ open }">▾</span>
|
||||
</button>
|
||||
<button type="button" class="direct-recharge-btn" @click="goRecharge">
|
||||
{{ t('recharge.title') }}
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="cash-panel">
|
||||
<div class="panel-row">
|
||||
@@ -105,6 +108,32 @@ onUnmounted(() => {
|
||||
.cash-chip-wrap {
|
||||
position: relative;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.direct-recharge-btn {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: var(--tertiary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.direct-recharge-btn:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.direct-recharge-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cash-chip {
|
||||
|
||||
@@ -89,7 +89,7 @@ function onConfirm() {
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -125,6 +125,7 @@ function onConfirm() {
|
||||
line-height: 1.6;
|
||||
color: #c8c8c8;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
|
||||
350
apps/player/src/components/FloatingMailbox.vue
Normal file
350
apps/player/src/components/FloatingMailbox.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import MessageListPanel from './MessageListPanel.vue';
|
||||
import CustomerServicePanel from './CustomerServicePanel.vue';
|
||||
import MobileMessageDetailView from '../views/MobileMessageDetailView.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerMessages } from '../composables/usePlayerMessages';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
import { useFloatingMailbox } from '../composables/useFloatingMailbox';
|
||||
import { useViewport } from '../composables/useViewport';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
type HubTab = 'messages' | 'support';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { isDesktop } = useViewport();
|
||||
const auth = useAuthStore();
|
||||
const { inboxEnabled, hubTitleKey } = useInboxFeature();
|
||||
const { unreadCount, messages, listLoaded, refreshUnreadCount, markAllRead, deleteAllMessages } = usePlayerMessages();
|
||||
const { isOpen, toggle, close } = useFloatingMailbox();
|
||||
|
||||
const markingAll = ref(false);
|
||||
const deletingAll = ref(false);
|
||||
const activeTab = ref<HubTab>('messages');
|
||||
const selectedMessageId = ref<string | null>(null);
|
||||
|
||||
const unreadInList = computed(() => messages.value.filter((item) => !item.isRead).length);
|
||||
const hasMessages = computed(() => listLoaded.value && messages.value.length > 0);
|
||||
const showMessageActions = computed(
|
||||
() => inboxEnabled.value && activeTab.value === 'messages' && auth.token && hasMessages.value && !selectedMessageId.value,
|
||||
);
|
||||
|
||||
const isBettingDesktop = computed(() => {
|
||||
if (!isDesktop.value) return false;
|
||||
const p = route.path;
|
||||
return p === '/bet' || p.startsWith('/match/') || p.startsWith('/outright/');
|
||||
});
|
||||
|
||||
watch(isOpen, (opened) => {
|
||||
if (opened) {
|
||||
if (!inboxEnabled.value) activeTab.value = 'support';
|
||||
refreshUnreadCount();
|
||||
} else {
|
||||
selectedMessageId.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
function switchTab(tab: HubTab) {
|
||||
if (!inboxEnabled.value || tab === activeTab.value) return;
|
||||
activeTab.value = tab;
|
||||
selectedMessageId.value = null;
|
||||
}
|
||||
|
||||
async function onMarkAllRead() {
|
||||
if (!unreadInList.value || markingAll.value) return;
|
||||
markingAll.value = true;
|
||||
try {
|
||||
await markAllRead();
|
||||
await refreshUnreadCount();
|
||||
} finally {
|
||||
markingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteAll() {
|
||||
if (deletingAll.value || !messages.value.length) return;
|
||||
if (!confirm(t('messages.delete_all_confirm'))) return;
|
||||
deletingAll.value = true;
|
||||
try {
|
||||
await deleteAllMessages();
|
||||
await refreshUnreadCount();
|
||||
} finally {
|
||||
deletingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageClick(id: string) {
|
||||
selectedMessageId.value = id;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isOpen.value) refreshUnreadCount();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="floating-mailbox" :class="{ 'is-betting-desktop': isBettingDesktop }">
|
||||
<!-- Floating Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="fab-btn"
|
||||
:class="{ 'fab-active': isOpen }"
|
||||
@click="toggle"
|
||||
:aria-label="t(hubTitleKey)"
|
||||
>
|
||||
<svg class="fab-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="9" cy="11" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="11" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="11" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
<span v-if="auth.user && inboxEnabled && unreadCount > 0" class="badge">
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Popup Panel -->
|
||||
<Transition name="fade-slide">
|
||||
<div v-if="isOpen" class="popup-panel">
|
||||
<div class="popup-header">
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-if="inboxEnabled"
|
||||
type="button"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'messages' }"
|
||||
@click="switchTab('messages')"
|
||||
>
|
||||
{{ t('messages.tab_inbox') }}
|
||||
<span v-if="unreadInList > 0" class="tab-badge">{{ unreadInList }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'support' }"
|
||||
@click="switchTab('support')"
|
||||
>
|
||||
{{ t('messages.tab_support') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<button v-if="showMessageActions && unreadInList > 0" type="button" class="action-btn" @click="onMarkAllRead" :disabled="markingAll">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/></svg>
|
||||
</button>
|
||||
<button v-if="showMessageActions" type="button" class="action-btn danger" @click="onDeleteAll" :disabled="deletingAll">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="action-btn close" @click="close">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="popup-body">
|
||||
<template v-if="activeTab === 'messages' && inboxEnabled">
|
||||
<MobileMessageDetailView v-if="selectedMessageId" :id="selectedMessageId" embedded @back="selectedMessageId = null" />
|
||||
<MessageListPanel v-else class="panel-content" @click-message="handleMessageClick" />
|
||||
</template>
|
||||
<CustomerServicePanel v-else class="panel-content" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.floating-mailbox {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fab-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fab-btn:hover {
|
||||
transform: scale(1.05);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.fab-active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: var(--danger, #ff4d4f);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--bg-card);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.popup-panel {
|
||||
position: absolute;
|
||||
bottom: 76px;
|
||||
right: 0;
|
||||
width: 380px;
|
||||
height: 600px;
|
||||
max-height: calc(100vh - 120px);
|
||||
background: var(--bg-card, #141414);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--primary-light);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.tab-btn.active .tab-badge {
|
||||
background: var(--primary);
|
||||
color: #141414;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
color: var(--danger, #ff4d4f);
|
||||
background: rgba(255, 77, 79, 0.1);
|
||||
}
|
||||
|
||||
.action-btn.close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
height: calc(100% - 48px);
|
||||
position: relative;
|
||||
background: #0f0f0f;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from,
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
}
|
||||
|
||||
.floating-mailbox.is-betting-desktop {
|
||||
right: calc(var(--desktop-right-betslip-w, 288px) + 12px);
|
||||
bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
248
apps/player/src/components/HomeAnnouncementCard.vue
Normal file
248
apps/player/src/components/HomeAnnouncementCard.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { stripHtml } from '../utils/html';
|
||||
|
||||
const AUTOPLAY_MS = 4000;
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { announcementItems } = usePlayerHome();
|
||||
|
||||
const currentPage = ref(1);
|
||||
const paused = ref(false);
|
||||
const itemsPerPage = 3;
|
||||
|
||||
let autoplayTimer = 0;
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(announcementItems.value.length / itemsPerPage)));
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage;
|
||||
return announcementItems.value.slice(start, start + itemsPerPage);
|
||||
});
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push(`/announcements/${id}`);
|
||||
}
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'compact' });
|
||||
}
|
||||
|
||||
function itemTitle(item: (typeof announcementItems.value)[number]) {
|
||||
const title = item.translation?.title?.trim();
|
||||
if (title) return title;
|
||||
return stripHtml(item.translation?.body ?? '') || t('home.announcement_badge');
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
currentPage.value = currentPage.value <= 1 ? totalPages.value : currentPage.value - 1;
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
currentPage.value = currentPage.value >= totalPages.value ? 1 : currentPage.value + 1;
|
||||
}
|
||||
|
||||
function startAutoplay() {
|
||||
window.clearInterval(autoplayTimer);
|
||||
if (totalPages.value <= 1) return;
|
||||
autoplayTimer = window.setInterval(() => {
|
||||
if (paused.value) return;
|
||||
nextPage();
|
||||
}, AUTOPLAY_MS);
|
||||
}
|
||||
|
||||
function stopAutoplay() {
|
||||
window.clearInterval(autoplayTimer);
|
||||
autoplayTimer = 0;
|
||||
}
|
||||
|
||||
onMounted(startAutoplay);
|
||||
onUnmounted(stopAutoplay);
|
||||
|
||||
watch(totalPages, () => {
|
||||
if (currentPage.value > totalPages.value) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
startAutoplay();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="announcementItems.length > 0"
|
||||
class="home-announce-card"
|
||||
@mouseenter="paused = true"
|
||||
@mouseleave="paused = false"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="header-title">
|
||||
<svg class="announce-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
||||
</svg>
|
||||
<span>{{ t('announcements.title') || '系统公告' }}</span>
|
||||
</div>
|
||||
<div v-if="totalPages > 1" class="pagination">
|
||||
<button type="button" class="page-btn" aria-label="Previous" @click="prevPage">‹</button>
|
||||
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
|
||||
<button type="button" class="page-btn" aria-label="Next" @click="nextPage">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<Transition name="announce-carousel" mode="out-in">
|
||||
<div :key="currentPage" class="carousel-slide">
|
||||
<button
|
||||
v-for="item in paginatedItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="announce-row"
|
||||
@click="openDetail(item.id)"
|
||||
>
|
||||
<span class="row-main">
|
||||
<span class="title">{{ itemTitle(item) }}</span>
|
||||
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
|
||||
</span>
|
||||
<span class="chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-announce-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(201, 162, 39, 0.05);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.announce-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: #1a1a1a;
|
||||
color: var(--gold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.announce-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.announce-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.announce-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--gold);
|
||||
font-size: 18px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.announce-carousel-enter-active,
|
||||
.announce-carousel-leave-active {
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
|
||||
.announce-carousel-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.announce-carousel-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,9 @@ import { stripHtml } from '../utils/html';
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const emit = defineEmits<{
|
||||
(e: 'click-message', id: string): void;
|
||||
}>();
|
||||
const {
|
||||
messages,
|
||||
loading,
|
||||
@@ -50,7 +53,7 @@ function messagePreview(item: PlayerMessage) {
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push(`/messages/${id}`);
|
||||
emit('click-message', id);
|
||||
}
|
||||
|
||||
async function fetchPage(nextPage: number, append = false) {
|
||||
@@ -178,8 +181,6 @@ onActivated(tryLoad);
|
||||
|
||||
<style scoped>
|
||||
.message-list-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.guest-hint,
|
||||
|
||||
@@ -7,7 +7,7 @@ const props = withDefaults(
|
||||
teamCode?: string;
|
||||
teamName?: string;
|
||||
logoUrl?: string | null;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}>(),
|
||||
{ size: 'md' },
|
||||
);
|
||||
@@ -80,6 +80,11 @@ watch(
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.team-emblem--xl {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
/* 国旗:横向比例 + 铺满 */
|
||||
.team-emblem:not(.team-emblem--logo) {
|
||||
object-fit: cover;
|
||||
@@ -102,6 +107,11 @@ watch(
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.team-emblem--xl:not(.team-emblem--logo) {
|
||||
width: 80px;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
/* 队徽:正方形容器 + 完整显示 */
|
||||
.team-emblem--logo {
|
||||
object-fit: contain;
|
||||
@@ -129,4 +139,8 @@ watch(
|
||||
.team-emblem--lg.team-emblem--placeholder {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.team-emblem--xl.team-emblem--placeholder {
|
||||
font-size: 30px;
|
||||
}
|
||||
</style>
|
||||
|
||||
121
apps/player/src/components/desktop/AccountSideNav.vue
Normal file
121
apps/player/src/components/desktop/AccountSideNav.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const isWalletSection = computed(
|
||||
() => route.path.startsWith('/wallet') || route.path === '/profile/cashbacks',
|
||||
);
|
||||
|
||||
const hubTitleKey = computed(() => (isWalletSection.value ? 'nav.wallet' : 'nav.bet_history'));
|
||||
|
||||
const menuItems = computed(() => {
|
||||
if (isWalletSection.value) {
|
||||
return [
|
||||
{
|
||||
path: '/wallet',
|
||||
labelKey: 'nav.wallet',
|
||||
match: (p: string) =>
|
||||
p === '/wallet' ||
|
||||
p === '/wallet/detail' ||
|
||||
p === '/wallet/recharge' ||
|
||||
p.startsWith('/wallet/transactions'),
|
||||
},
|
||||
{
|
||||
path: '/wallet/recharge/history',
|
||||
labelKey: 'wallet.recharge_history',
|
||||
match: (p: string) => p.startsWith('/wallet/recharge/history'),
|
||||
},
|
||||
{
|
||||
path: '/wallet/cashbacks',
|
||||
labelKey: 'wallet.cashbacks_tab',
|
||||
match: (p: string) => p === '/wallet/cashbacks' || p === '/profile/cashbacks',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
path: '/bets',
|
||||
labelKey: 'nav.bet_history',
|
||||
match: (p: string) => p === '/bets' || p.startsWith('/bets/'),
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="account-side-nav">
|
||||
<div class="hub-nav-title">{{ t(hubTitleKey) }}</div>
|
||||
<RouterLink
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="nav-item hover-bg"
|
||||
:class="{ active: item.match(route.path) }"
|
||||
>
|
||||
<span class="dot"></span>
|
||||
<span class="label">{{ t(item.labelKey) || item.path }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account-side-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.hub-nav-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 6px 14px 10px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.nav-item.active .dot {
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 8px var(--primary);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #333;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.label {
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
1967
apps/player/src/components/desktop/BetSlipPanel.vue
Normal file
1967
apps/player/src/components/desktop/BetSlipPanel.vue
Normal file
@@ -0,0 +1,1967 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router';
|
||||
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
||||
import { useBetSlipStore, type ParlaySlipError, type SlipItem, type SlipMode } from '../../stores/betSlip';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { formatMoney, parseAmount } from '../../utils/localeDisplay';
|
||||
import api from '../../api';
|
||||
import { usePlayerProfile } from '../../composables/usePlayerProfile';
|
||||
import BetSuccessOverlay from '../BetSuccessOverlay.vue';
|
||||
import ConfirmDialog from '../ConfirmDialog.vue';
|
||||
import GoldSpinner from '../GoldSpinner.vue';
|
||||
import { type BetHistoryItem } from '../BetHistoryCard.vue';
|
||||
import { buildBetPlaceConfirmMessage } from '../../utils/betPlaceConfirmMessage';
|
||||
import { useDesktopBetLocate } from '../../composables/useDesktopBetLocate';
|
||||
|
||||
type PanelTab = SlipMode | 'history';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
const { refreshProfile } = usePlayerProfile();
|
||||
const { requestLocate } = useDesktopBetLocate();
|
||||
|
||||
const activeTab = ref<PanelTab>('single');
|
||||
const historyScope = ref<'all' | 'match'>('all');
|
||||
const historyItems = ref<BetHistoryItem[]>([]);
|
||||
const historyTotal = ref(0);
|
||||
const historyPage = ref(1);
|
||||
const historyPageSize = 15;
|
||||
const historyLoading = ref(false);
|
||||
const historyInitialLoading = ref(false);
|
||||
const loading = ref(false);
|
||||
const balanceLoading = ref(false);
|
||||
const balance = ref<number | null>(null);
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
const showSuccess = ref(false);
|
||||
const showPlaceConfirm = ref(false);
|
||||
const placeConfirmMessage = ref('');
|
||||
const pendingSingleItem = ref<SlipItem | null>(null);
|
||||
const MIN_STAKE = 5;
|
||||
const MAX_STAKE_INTEGER_LENGTH = 9;
|
||||
const stakeInput = ref('');
|
||||
const itemStakeInputs = ref<Record<string, string>>({});
|
||||
const ODDS_POLL_MS = 5000;
|
||||
|
||||
type OddsDelta = {
|
||||
oldOdds: number;
|
||||
newOdds: number;
|
||||
newVersion: string;
|
||||
suspended: boolean;
|
||||
};
|
||||
|
||||
interface SelectionOddsRow {
|
||||
id: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
status: string;
|
||||
marketStatus: string;
|
||||
marketShowOnPlayer: boolean;
|
||||
matchStatus: string;
|
||||
}
|
||||
|
||||
const oddsDeltas = ref<Record<string, OddsDelta>>({});
|
||||
let oddsPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const currentMatchId = computed(() => {
|
||||
const match = route.path.match(/^\/match\/([^/]+)/);
|
||||
if (match) return match[1];
|
||||
const outright = route.path.match(/^\/outright\/([^/]+)/);
|
||||
return outright ? outright[1] : '';
|
||||
});
|
||||
|
||||
const showMatchHistoryFilter = computed(() => Boolean(currentMatchId.value));
|
||||
|
||||
const singleSlipItems = computed<SlipItem[]>(() => {
|
||||
if (slip.singleCartItems.length) return slip.singleCartItems;
|
||||
return slip.singleItem ? [slip.singleItem] : [];
|
||||
});
|
||||
|
||||
const activeItems = computed<SlipItem[]>(() => {
|
||||
if (activeTab.value === 'history') return [];
|
||||
if (activeTab.value === 'parlay') return slip.parlayItems;
|
||||
return singleSlipItems.value;
|
||||
});
|
||||
|
||||
const historyHasMore = computed(
|
||||
() => historyItems.value.length < historyTotal.value,
|
||||
);
|
||||
|
||||
const activeCount = computed(() => activeItems.value.length);
|
||||
|
||||
function effectiveOdds(item: SlipItem) {
|
||||
return oddsDeltas.value[item.selectionId]?.newOdds ?? item.odds;
|
||||
}
|
||||
|
||||
const activeTotalOdds = computed(() =>
|
||||
activeItems.value.reduce((acc, item) => acc * effectiveOdds(item), 1),
|
||||
);
|
||||
const activeEstimatedReturn = computed(() => {
|
||||
if (!activeItems.value.length) return 0;
|
||||
if (activeTab.value === 'parlay') {
|
||||
if (!Number.isFinite(slip.stake) || slip.stake <= 0) return 0;
|
||||
return slip.stake * activeTotalOdds.value;
|
||||
}
|
||||
return activeItems.value.reduce((acc, item) => {
|
||||
const s = slip.getItemStake(item.selectionId);
|
||||
if (!Number.isFinite(s) || s <= 0) return acc;
|
||||
return acc + s * effectiveOdds(item);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const activeTotalStake = computed(() => {
|
||||
if (activeTab.value === 'parlay') return Number(slip.stake) || 0;
|
||||
if (activeTab.value !== 'single') return 0;
|
||||
return singleSlipItems.value.reduce((acc, item) => acc + slip.getItemStake(item.selectionId), 0);
|
||||
});
|
||||
|
||||
const hasSuspendedSelections = computed(() =>
|
||||
Object.values(oddsDeltas.value).some((delta) => delta.suspended),
|
||||
);
|
||||
|
||||
const hasPendingOddsChanges = computed(() =>
|
||||
Object.values(oddsDeltas.value).some((delta) => !delta.suspended),
|
||||
);
|
||||
|
||||
const oddsWarningText = computed(() => {
|
||||
if (hasSuspendedSelections.value) return t('bet.odds_suspended');
|
||||
if (hasPendingOddsChanges.value) return t('bet.odds_changed');
|
||||
return '';
|
||||
});
|
||||
|
||||
const submitButtonLabel = computed(() => {
|
||||
if (loading.value) return t('bet.placing');
|
||||
if (hasPendingOddsChanges.value) return t('bet.accept_changes_place');
|
||||
if (activeTab.value === 'single') return t('bet.slip_place_all');
|
||||
return t('bet.place_bet_short');
|
||||
});
|
||||
|
||||
const canSubmitWithOdds = computed(() => canSubmitActive.value && !hasSuspendedSelections.value);
|
||||
|
||||
const canSubmitActive = computed(() => {
|
||||
if (activeTab.value === 'parlay') {
|
||||
return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS;
|
||||
}
|
||||
if (activeTab.value !== 'single') return false;
|
||||
return singleSlipItems.value.length > 0 && singleSlipItems.value.every((item) => item.allowSingle !== false);
|
||||
});
|
||||
|
||||
const singleParlayOnlyHint = computed(
|
||||
() =>
|
||||
slip.mode === 'single' &&
|
||||
Boolean(slip.singleItem) &&
|
||||
slip.singleItem!.allowSingle === false &&
|
||||
slip.singleItem!.allowParlay !== false,
|
||||
);
|
||||
|
||||
const balanceText = computed(() => {
|
||||
if (balanceLoading.value) return t('bet.loading');
|
||||
if (balance.value == null) return '--';
|
||||
return formatMoney(balance.value, locale.value);
|
||||
});
|
||||
|
||||
const stakeText = computed(() => formatMoney(activeTotalStake.value, locale.value));
|
||||
const estimatedReturnText = computed(() => formatMoney(activeEstimatedReturn.value, locale.value));
|
||||
const totalOddsText = computed(() => activeTotalOdds.value.toFixed(4).replace(/0+$/, '').replace(/\.$/, ''));
|
||||
|
||||
const parlayWarning = computed(() => {
|
||||
if (activeTab.value !== 'parlay') return '';
|
||||
if (slip.lastParlayError) return parlayErrorMessage(slip.lastParlayError);
|
||||
if (slip.parlayItems.length > 0 && slip.parlayItems.length < PARLAY_MIN_LEGS) {
|
||||
return t('bet.parlay_need_more');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const singleInParlay = computed(() => {
|
||||
const item = slip.singleItem;
|
||||
return Boolean(item && slip.parlayItems.some((leg) => leg.selectionId === item.selectionId));
|
||||
});
|
||||
|
||||
const showFooterParlayAction = computed(() => slip.mode === 'single' && Boolean(slip.singleItem));
|
||||
|
||||
function genId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function selectTab(tab: PanelTab) {
|
||||
activeTab.value = tab;
|
||||
error.value = '';
|
||||
if (tab === 'history') {
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt(route.fullPath);
|
||||
activeTab.value = slip.mode;
|
||||
return;
|
||||
}
|
||||
if (showMatchHistoryFilter.value) {
|
||||
historyScope.value = 'match';
|
||||
}
|
||||
void loadHistory(true);
|
||||
return;
|
||||
}
|
||||
slip.setMode(tab);
|
||||
syncStakeInputFromSlip();
|
||||
}
|
||||
|
||||
function selectHistoryScope(scope: 'all' | 'match') {
|
||||
if (historyScope.value === scope) return;
|
||||
historyScope.value = scope;
|
||||
void loadHistory(true);
|
||||
}
|
||||
|
||||
async function loadHistory(reset = false) {
|
||||
if (!auth.token || activeTab.value !== 'history') return;
|
||||
if (historyLoading.value) return;
|
||||
if (reset) {
|
||||
historyPage.value = 1;
|
||||
historyInitialLoading.value = true;
|
||||
}
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
page: historyPage.value,
|
||||
pageSize: historyPageSize,
|
||||
};
|
||||
if (historyScope.value === 'match' && currentMatchId.value) {
|
||||
params.matchId = currentMatchId.value;
|
||||
}
|
||||
const { data } = await api.get('/player/bets', { params });
|
||||
const result = data.data ?? { items: [], total: 0 };
|
||||
const items = (result.items ?? []) as BetHistoryItem[];
|
||||
historyTotal.value = result.total ?? 0;
|
||||
historyItems.value = reset ? items : [...historyItems.value, ...items];
|
||||
} catch {
|
||||
if (reset) {
|
||||
historyItems.value = [];
|
||||
historyTotal.value = 0;
|
||||
}
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
historyInitialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMoreHistory() {
|
||||
if (!historyHasMore.value || historyLoading.value) return;
|
||||
historyPage.value += 1;
|
||||
void loadHistory(false);
|
||||
}
|
||||
|
||||
function historyStatusLabel(status: string) {
|
||||
const s = status.toUpperCase();
|
||||
if (s === 'WON' || s === 'WIN') return t('history.status_won');
|
||||
if (s === 'LOST' || s === 'LOSE') return t('history.status_lost');
|
||||
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return t('history.status_push');
|
||||
return t('history.status_pending');
|
||||
}
|
||||
|
||||
function historyStatusClass(status: string) {
|
||||
const s = status.toUpperCase();
|
||||
if (s === 'WON' || s === 'WIN') return 'hist-won';
|
||||
if (s === 'LOST' || s === 'LOSE') return 'hist-lost';
|
||||
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return 'hist-push';
|
||||
return 'hist-pending';
|
||||
}
|
||||
|
||||
function historyPickLabel(bet: BetHistoryItem) {
|
||||
if (bet.betType === 'PARLAY' || bet.isParlay) {
|
||||
const n = bet.legCount ?? bet.legs?.length ?? 0;
|
||||
return n > 0 ? t('history.parlay_title', { n }) : t('history.parlay_league');
|
||||
}
|
||||
return bet.pickLabel || bet.matchTitle || '-';
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
slip.removeItem(id);
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function locateSlipItem(item: SlipItem) {
|
||||
const isOutright = item.marketType === 'OUTRIGHT_WINNER';
|
||||
const targetPath = isOutright ? `/outright/${item.matchId}` : `/match/${item.matchId}`;
|
||||
requestLocate({
|
||||
matchId: item.matchId,
|
||||
selectionId: item.selectionId,
|
||||
marketId: item.marketId,
|
||||
marketType: item.marketType,
|
||||
});
|
||||
if (route.path !== targetPath) {
|
||||
void router.push(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveSlip() {
|
||||
error.value = '';
|
||||
oddsDeltas.value = {};
|
||||
if (activeTab.value === 'parlay') {
|
||||
slip.clearParlay();
|
||||
} else if (activeTab.value === 'single') {
|
||||
slip.clearSingle();
|
||||
}
|
||||
syncStakeInputFromSlip();
|
||||
}
|
||||
|
||||
function stakeAmountToInput(amount: number) {
|
||||
if (!Number.isFinite(amount) || amount <= 0) return '';
|
||||
const rounded = Math.round(amount * 100) / 100;
|
||||
return Number.isInteger(rounded)
|
||||
? String(rounded)
|
||||
: rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function sanitizeStakeInput(raw: string) {
|
||||
const onlyAmountChars = raw.replace(/[^\d.]/g, '');
|
||||
const [integerRaw, ...fractionParts] = onlyAmountChars.split('.');
|
||||
const integerPart = integerRaw.replace(/^0+(?=\d)/, '').slice(0, MAX_STAKE_INTEGER_LENGTH);
|
||||
if (!fractionParts.length) return integerPart;
|
||||
const fractionPart = fractionParts.join('').slice(0, 2);
|
||||
return `${integerPart || '0'}.${fractionPart}`;
|
||||
}
|
||||
|
||||
function commitStakeInput(raw: string) {
|
||||
const clean = sanitizeStakeInput(raw);
|
||||
stakeInput.value = clean;
|
||||
const amount = Number.parseFloat(clean);
|
||||
slip.stake = Number.isFinite(amount) ? amount : 0;
|
||||
}
|
||||
|
||||
function syncItemStakeInputs(items: SlipItem[]) {
|
||||
const next: Record<string, string> = {};
|
||||
for (const item of items) {
|
||||
slip.initItemStake(item.selectionId);
|
||||
next[item.selectionId] = stakeAmountToInput(slip.getItemStake(item.selectionId));
|
||||
}
|
||||
itemStakeInputs.value = next;
|
||||
}
|
||||
|
||||
function itemStakeInputValue(selectionId: string) {
|
||||
if (!(selectionId in itemStakeInputs.value)) {
|
||||
slip.initItemStake(selectionId);
|
||||
itemStakeInputs.value = {
|
||||
...itemStakeInputs.value,
|
||||
[selectionId]: stakeAmountToInput(slip.getItemStake(selectionId)),
|
||||
};
|
||||
}
|
||||
return itemStakeInputs.value[selectionId];
|
||||
}
|
||||
|
||||
function onItemStakeInput(selectionId: string, event: Event) {
|
||||
const clean = sanitizeStakeInput((event.target as HTMLInputElement).value);
|
||||
itemStakeInputs.value = { ...itemStakeInputs.value, [selectionId]: clean };
|
||||
const amount = Number.parseFloat(clean);
|
||||
slip.setItemStake(selectionId, Number.isFinite(amount) ? amount : 0);
|
||||
}
|
||||
|
||||
function clearItemStakeInput(selectionId: string) {
|
||||
itemStakeInputs.value = { ...itemStakeInputs.value, [selectionId]: '' };
|
||||
slip.setItemStake(selectionId, 0);
|
||||
}
|
||||
|
||||
function addItemStake(selectionId: string, amount: number) {
|
||||
const next = (slip.getItemStake(selectionId) || 0) + amount;
|
||||
const normalized = Math.round(Math.max(MIN_STAKE, next) * 100) / 100;
|
||||
slip.setItemStake(selectionId, normalized);
|
||||
itemStakeInputs.value = {
|
||||
...itemStakeInputs.value,
|
||||
[selectionId]: stakeAmountToInput(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
function setItemMaxStake(selectionId: string) {
|
||||
if (balance.value == null || balance.value <= 0) return;
|
||||
const normalized = Math.round(balance.value * 100) / 100;
|
||||
slip.setItemStake(selectionId, normalized);
|
||||
itemStakeInputs.value = {
|
||||
...itemStakeInputs.value,
|
||||
[selectionId]: stakeAmountToInput(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
function itemEstReturn(item: SlipItem) {
|
||||
const s = slip.getItemStake(item.selectionId);
|
||||
if (!Number.isFinite(s) || s <= 0) return 0;
|
||||
return s * effectiveOdds(item);
|
||||
}
|
||||
|
||||
function syncStakeInputFromSlip() {
|
||||
stakeInput.value = stakeAmountToInput(Number(slip.stake) || 0);
|
||||
if (activeTab.value === 'single') {
|
||||
syncItemStakeInputs(singleSlipItems.value);
|
||||
}
|
||||
}
|
||||
|
||||
function onStakeInput(event: Event) {
|
||||
commitStakeInput((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function clearStakeInput() {
|
||||
commitStakeInput('');
|
||||
}
|
||||
|
||||
function setStake(amount: number, enforceMinimum = true) {
|
||||
if (!Number.isFinite(amount)) return;
|
||||
const normalized = Math.round((enforceMinimum ? Math.max(MIN_STAKE, amount) : amount) * 100) / 100;
|
||||
slip.stake = normalized;
|
||||
stakeInput.value = stakeAmountToInput(normalized);
|
||||
}
|
||||
|
||||
function addStake(amount: number) {
|
||||
setStake((Number(slip.stake) || 0) + amount);
|
||||
}
|
||||
|
||||
function setMaxStake() {
|
||||
if (balance.value != null && balance.value > 0) setStake(balance.value, false);
|
||||
}
|
||||
|
||||
function stopOddsPolling() {
|
||||
if (oddsPollTimer) {
|
||||
clearInterval(oddsPollTimer);
|
||||
oddsPollTimer = null;
|
||||
}
|
||||
oddsDeltas.value = {};
|
||||
}
|
||||
|
||||
function acceptPendingOdds() {
|
||||
for (const [selectionId, delta] of Object.entries(oddsDeltas.value)) {
|
||||
if (!delta.suspended) {
|
||||
slip.updateSelectionOdds(selectionId, delta.newOdds, delta.newVersion);
|
||||
}
|
||||
}
|
||||
oddsDeltas.value = {};
|
||||
}
|
||||
|
||||
function acceptPendingOddsForSelection(selectionId: string) {
|
||||
const delta = oddsDeltas.value[selectionId];
|
||||
if (!delta || delta.suspended) return;
|
||||
slip.updateSelectionOdds(selectionId, delta.newOdds, delta.newVersion);
|
||||
const next = { ...oddsDeltas.value };
|
||||
delete next[selectionId];
|
||||
oddsDeltas.value = next;
|
||||
}
|
||||
|
||||
function isItemSuspended(item: SlipItem) {
|
||||
return Boolean(oddsDeltaFor(item.selectionId)?.suspended);
|
||||
}
|
||||
|
||||
function canPlaceSingleItem(item: SlipItem) {
|
||||
if (item.allowSingle === false) return false;
|
||||
if (isItemSuspended(item)) return false;
|
||||
return slip.getItemStake(item.selectionId) >= MIN_STAKE;
|
||||
}
|
||||
|
||||
function validateSingleItemPlace(item: SlipItem): boolean {
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt();
|
||||
return false;
|
||||
}
|
||||
if (item.allowSingle === false) {
|
||||
error.value = t('bet.slip_parlay_only_hint');
|
||||
return false;
|
||||
}
|
||||
const itemStake = slip.getItemStake(item.selectionId);
|
||||
if (itemStake < MIN_STAKE) {
|
||||
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
|
||||
return false;
|
||||
}
|
||||
if (balance.value != null && itemStake > balance.value) {
|
||||
error.value = t('bet.outright_insufficient');
|
||||
return false;
|
||||
}
|
||||
if (isItemSuspended(item)) {
|
||||
error.value = t('bet.odds_suspended');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function onPlaceSingleItemClick(item: SlipItem) {
|
||||
error.value = '';
|
||||
if (!validateSingleItemPlace(item)) return;
|
||||
if (oddsDeltaFor(item.selectionId) && !isItemSuspended(item)) {
|
||||
acceptPendingOddsForSelection(item.selectionId);
|
||||
}
|
||||
pendingSingleItem.value = item;
|
||||
const stake = slip.getItemStake(item.selectionId);
|
||||
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
|
||||
mode: 'single',
|
||||
items: [item],
|
||||
totalStake: stake,
|
||||
totalReturn: stake * effectiveOdds(item),
|
||||
formatMoney: (amount) => formatMoney(amount, locale.value),
|
||||
getStake: () => stake,
|
||||
getOdds: () => effectiveOdds(item),
|
||||
});
|
||||
showPlaceConfirm.value = true;
|
||||
}
|
||||
|
||||
async function pollSelectionsOdds() {
|
||||
const items = activeItems.value;
|
||||
if (!items.length) return;
|
||||
|
||||
try {
|
||||
const ids = items.map((item) => item.selectionId).join(',');
|
||||
const { data } = await api.get('/player/selections/odds', { params: { ids } });
|
||||
const rows: SelectionOddsRow[] = data.data?.items ?? [];
|
||||
const rowMap = new Map(rows.map((row) => [row.id, row]));
|
||||
const next: Record<string, OddsDelta> = {};
|
||||
|
||||
for (const item of items) {
|
||||
const row = rowMap.get(item.selectionId);
|
||||
if (!row) continue;
|
||||
|
||||
const suspended =
|
||||
row.status !== 'OPEN' ||
|
||||
row.marketStatus !== 'OPEN' ||
|
||||
row.marketShowOnPlayer === false ||
|
||||
row.matchStatus !== 'PUBLISHED';
|
||||
const newOdds = parseFloat(row.odds);
|
||||
const versionChanged = row.oddsVersion !== item.oddsVersion;
|
||||
const oddsChanged = Number.isFinite(newOdds) && Math.abs(newOdds - item.odds) > 0.0001;
|
||||
|
||||
if (suspended || versionChanged || oddsChanged) {
|
||||
const existing = oddsDeltas.value[item.selectionId];
|
||||
next[item.selectionId] = {
|
||||
oldOdds: existing?.oldOdds ?? item.odds,
|
||||
newOdds: Number.isFinite(newOdds) ? newOdds : item.odds,
|
||||
newVersion: row.oddsVersion,
|
||||
suspended,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
oddsDeltas.value = next;
|
||||
} catch {
|
||||
/* silent retry */
|
||||
}
|
||||
}
|
||||
|
||||
function startOddsPolling() {
|
||||
stopOddsPolling();
|
||||
void pollSelectionsOdds();
|
||||
oddsPollTimer = setInterval(() => {
|
||||
void pollSelectionsOdds();
|
||||
}, ODDS_POLL_MS);
|
||||
}
|
||||
|
||||
function oddsDeltaFor(selectionId: string) {
|
||||
return oddsDeltas.value[selectionId];
|
||||
}
|
||||
|
||||
function oddsTrendClass(delta: OddsDelta) {
|
||||
if (delta.suspended) return 'odds-suspended';
|
||||
return delta.newOdds >= delta.oldOdds ? 'odds-up' : 'odds-down';
|
||||
}
|
||||
|
||||
function singlesForSubmit() {
|
||||
return [...singleSlipItems.value];
|
||||
}
|
||||
|
||||
function validatePlaceBet(): boolean {
|
||||
if (!activeItems.value.length) return false;
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt();
|
||||
return false;
|
||||
}
|
||||
if (activeTab.value === 'parlay') {
|
||||
if (slip.stake < MIN_STAKE) {
|
||||
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
|
||||
return false;
|
||||
}
|
||||
if (balance.value != null && slip.stake > balance.value) {
|
||||
error.value = t('bet.outright_insufficient');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const singles = singlesForSubmit();
|
||||
let totalStake = 0;
|
||||
for (const item of singles) {
|
||||
const itemStake = slip.getItemStake(item.selectionId);
|
||||
if (itemStake < MIN_STAKE) {
|
||||
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
|
||||
return false;
|
||||
}
|
||||
totalStake += itemStake;
|
||||
}
|
||||
if (balance.value != null && totalStake > balance.value) {
|
||||
error.value = t('bet.outright_insufficient');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (activeTab.value === 'parlay' && !canSubmitActive.value) {
|
||||
error.value = slip.parlayItems.length > PARLAY_MAX_LEGS
|
||||
? t('bet.parlay_max_legs')
|
||||
: t('bet.parlay_need_more');
|
||||
return false;
|
||||
}
|
||||
if (hasSuspendedSelections.value) {
|
||||
error.value = t('bet.odds_suspended');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function onPlaceBetClick() {
|
||||
error.value = '';
|
||||
if (!validatePlaceBet()) return;
|
||||
if (hasPendingOddsChanges.value) {
|
||||
acceptPendingOdds();
|
||||
}
|
||||
pendingSingleItem.value = null;
|
||||
const singles = singlesForSubmit();
|
||||
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
|
||||
mode: activeTab.value === 'parlay' ? 'parlay' : 'single',
|
||||
items: activeTab.value === 'parlay' ? [...slip.parlayItems] : singles,
|
||||
totalStake: activeTotalStake.value,
|
||||
totalReturn: activeEstimatedReturn.value,
|
||||
totalOdds: activeTab.value === 'parlay' ? activeTotalOdds.value : undefined,
|
||||
formatMoney: (amount) => formatMoney(amount, locale.value),
|
||||
getStake: (item) => slip.getItemStake(item.selectionId),
|
||||
getOdds: (item) => effectiveOdds(item),
|
||||
});
|
||||
showPlaceConfirm.value = true;
|
||||
}
|
||||
|
||||
async function executePlaceSingleItem(item: SlipItem) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
|
||||
try {
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
stake: slip.getItemStake(item.selectionId),
|
||||
requestId: genId(),
|
||||
});
|
||||
slip.removeItem(item.selectionId);
|
||||
success.value = t('bet.place_success');
|
||||
showSuccess.value = true;
|
||||
await Promise.all([loadBalance(), refreshProfile()]);
|
||||
setTimeout(() => {
|
||||
if (showSuccess.value) onSuccessDone();
|
||||
}, 2200);
|
||||
} catch (e: unknown) {
|
||||
error.value =
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||
t('bet.place_failed');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function executePlaceBet() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
|
||||
try {
|
||||
if (activeTab.value === 'parlay') {
|
||||
await api.post('/player/bets/parlay', {
|
||||
legs: slip.parlayItems.map((item) => ({
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
})),
|
||||
stake: slip.stake,
|
||||
requestId: genId(),
|
||||
});
|
||||
slip.clearParlay();
|
||||
} else {
|
||||
for (const item of singlesForSubmit()) {
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
stake: slip.getItemStake(item.selectionId),
|
||||
requestId: genId(),
|
||||
});
|
||||
}
|
||||
slip.clearSingle();
|
||||
}
|
||||
success.value = t('bet.place_success');
|
||||
showSuccess.value = true;
|
||||
if (activeTab.value === 'history') {
|
||||
void loadHistory(true);
|
||||
}
|
||||
await Promise.all([loadBalance(), refreshProfile()]);
|
||||
setTimeout(() => {
|
||||
if (showSuccess.value) onSuccessDone();
|
||||
}, 2200);
|
||||
} catch (e: unknown) {
|
||||
error.value =
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||
t('bet.place_failed');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPlaceBet() {
|
||||
showPlaceConfirm.value = false;
|
||||
const singleItem = pendingSingleItem.value;
|
||||
pendingSingleItem.value = null;
|
||||
if (singleItem) {
|
||||
await executePlaceSingleItem(singleItem);
|
||||
return;
|
||||
}
|
||||
await executePlaceBet();
|
||||
}
|
||||
|
||||
function onSuccessDone() {
|
||||
showSuccess.value = false;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
syncStakeInputFromSlip();
|
||||
}
|
||||
|
||||
async function loadBalance() {
|
||||
if (!auth.token) {
|
||||
balance.value = null;
|
||||
return;
|
||||
}
|
||||
balanceLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/profile');
|
||||
balance.value = parseAmount(data.data?.wallet?.availableBalance);
|
||||
} catch {
|
||||
balance.value = null;
|
||||
} finally {
|
||||
balanceLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parlayErrorMessage(reason: ParlaySlipError) {
|
||||
if (reason === 'MAX_LEGS') return t('bet.parlay_max_legs');
|
||||
if (reason === 'QUARTER_LINE') return t('bet.parlay_block_quarter');
|
||||
if (reason === 'OUTRIGHT') return t('bet.parlay_block_outright');
|
||||
if (reason === 'NOT_ALLOWED') return t('bet.parlay_block_not_allowed');
|
||||
if (reason === 'SAME_MATCH') return t('bet.slip_parlay_same_match');
|
||||
return t('bet.parlay_block_not_allowed');
|
||||
}
|
||||
|
||||
function addItemToParlay(item: SlipItem) {
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt(route.fullPath);
|
||||
return;
|
||||
}
|
||||
if (slip.parlayItems.some((leg) => leg.selectionId === item.selectionId)) {
|
||||
activeTab.value = 'parlay';
|
||||
slip.setMode('parlay');
|
||||
error.value = '';
|
||||
return;
|
||||
}
|
||||
const err = slip.addParlayLeg(item);
|
||||
activeTab.value = 'parlay';
|
||||
slip.setMode('parlay');
|
||||
if (err) {
|
||||
error.value = parlayErrorMessage(err);
|
||||
return;
|
||||
}
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function isItemInParlay(item: SlipItem) {
|
||||
return slip.parlayItems.some((leg) => leg.selectionId === item.selectionId);
|
||||
}
|
||||
|
||||
function parlayActionLabel(item: SlipItem) {
|
||||
return isItemInParlay(item) ? t('bet.slip_parlay_added_short') : t('bet.add_to_parlay');
|
||||
}
|
||||
|
||||
function addCurrentToParlay() {
|
||||
if (singleInParlay.value) {
|
||||
activeTab.value = 'parlay';
|
||||
slip.setMode('parlay');
|
||||
error.value = '';
|
||||
return;
|
||||
}
|
||||
const err = slip.addSingleToParlay();
|
||||
activeTab.value = 'parlay';
|
||||
slip.setMode('parlay');
|
||||
if (err) {
|
||||
error.value = parlayErrorMessage(err);
|
||||
return;
|
||||
}
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
watch(
|
||||
() => activeItems.value.map((item) => item.selectionId).join(','),
|
||||
(ids) => {
|
||||
if (ids) {
|
||||
if (activeTab.value === 'single') syncItemStakeInputs(singleSlipItems.value);
|
||||
startOddsPolling();
|
||||
} else {
|
||||
itemStakeInputs.value = {};
|
||||
stopOddsPolling();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => slip.mode,
|
||||
(mode) => {
|
||||
if (activeTab.value !== 'history') activeTab.value = mode;
|
||||
},
|
||||
);
|
||||
|
||||
watch(showMatchHistoryFilter, (show) => {
|
||||
if (!show && historyScope.value === 'match') {
|
||||
historyScope.value = 'all';
|
||||
if (activeTab.value === 'history') void loadHistory(true);
|
||||
}
|
||||
});
|
||||
|
||||
watch(currentMatchId, (id, prev) => {
|
||||
if (activeTab.value !== 'history') return;
|
||||
if (id && id !== prev && historyScope.value === 'match') {
|
||||
void loadHistory(true);
|
||||
}
|
||||
if (!id && historyScope.value === 'match') {
|
||||
historyScope.value = 'all';
|
||||
void loadHistory(true);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => auth.token,
|
||||
(token) => {
|
||||
if (!token && activeTab.value === 'history') {
|
||||
activeTab.value = slip.mode;
|
||||
historyItems.value = [];
|
||||
historyTotal.value = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
activeTab.value = slip.mode;
|
||||
if (activeTab.value === 'single' && !slip.singleItem && slip.parlayItems.length) {
|
||||
activeTab.value = 'parlay';
|
||||
slip.setMode('parlay');
|
||||
}
|
||||
syncStakeInputFromSlip();
|
||||
void loadBalance();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopOddsPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="betslip-panel">
|
||||
<div class="panel-head">
|
||||
<h3>{{ t('bet.bet_slip') || '投注单' }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="panel-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'single' }"
|
||||
@click="selectTab('single')"
|
||||
>
|
||||
{{ t('bet.slip_tab_single') }}
|
||||
<span v-if="slip.singleCount" class="badge">{{ slip.singleCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'parlay' }"
|
||||
@click="selectTab('parlay')"
|
||||
>
|
||||
{{ t('bet.slip_tab_parlay') }}
|
||||
<span v-if="slip.parlayCount" class="badge">{{ slip.parlayCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'history' }"
|
||||
@click="selectTab('history')"
|
||||
>
|
||||
{{ t('bet.slip_tab_history') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'history' && showMatchHistoryFilter" class="history-scope-bar">
|
||||
<button
|
||||
type="button"
|
||||
class="scope-chip"
|
||||
:class="{ active: historyScope === 'all' }"
|
||||
@click="selectHistoryScope('all')"
|
||||
>
|
||||
{{ t('bet.slip_history_all') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="scope-chip"
|
||||
:class="{ active: historyScope === 'match' }"
|
||||
@click="selectHistoryScope('match')"
|
||||
>
|
||||
{{ t('bet.slip_history_match') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<p v-if="activeTab !== 'history' && oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="panel-body">
|
||||
<template v-if="activeTab === 'history'">
|
||||
<div v-if="historyInitialLoading" class="history-loading">
|
||||
<GoldSpinner :size="28" />
|
||||
</div>
|
||||
<div v-else-if="!historyItems.length" class="empty-state">
|
||||
<svg class="empty-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10H7v-2h10v2zm0-4H7V7h10v2zm0 8H7v-2h10v2z" fill="currentColor"/>
|
||||
</svg>
|
||||
<p>{{ t('history.empty') }}</p>
|
||||
</div>
|
||||
<div v-else class="history-list">
|
||||
<RouterLink
|
||||
v-for="bet in historyItems"
|
||||
:key="bet.betNo"
|
||||
:to="`/bets/${bet.betNo}`"
|
||||
class="history-card"
|
||||
>
|
||||
<div class="history-card-top">
|
||||
<span class="history-pick">{{ historyPickLabel(bet) }}</span>
|
||||
<span class="history-status" :class="historyStatusClass(bet.status)">
|
||||
{{ historyStatusLabel(bet.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="historyScope === 'all' && bet.matchTitle" class="history-match">
|
||||
{{ bet.matchTitle }}
|
||||
</div>
|
||||
<div class="history-card-foot">
|
||||
<span class="history-stake">{{ t('history.stake') }} {{ formatMoney(bet.stake, locale) }}</span>
|
||||
<span class="history-return" :class="historyStatusClass(bet.status)">
|
||||
{{ formatMoney(bet.actualReturn || bet.potentialReturn, locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="history-meta">
|
||||
<span>{{ bet.betNo }}</span>
|
||||
<span>{{ bet.placedAt ? new Date(bet.placedAt).toLocaleString(locale) : '' }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<button
|
||||
v-if="historyHasMore && !historyInitialLoading"
|
||||
type="button"
|
||||
class="history-load-more"
|
||||
:disabled="historyLoading"
|
||||
@click="loadMoreHistory"
|
||||
>
|
||||
{{ historyLoading ? t('bet.loading') : t('bet.slip_history_load_more') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!activeItems.length" class="empty-state">
|
||||
<svg class="empty-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10H7v-2h10v2zm0-4H7V7h10v2zm0 8H7v-2h10v2z" fill="currentColor"/>
|
||||
</svg>
|
||||
<p>{{ activeTab === 'parlay' ? t('bet.slip_parlay_empty_hint') : t('bet.slip_empty_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="items-list">
|
||||
<p v-if="activeTab === 'parlay' && parlayWarning" class="panel-warning">{{ parlayWarning }}</p>
|
||||
<div
|
||||
v-for="item in activeItems"
|
||||
:key="item.selectionId"
|
||||
class="slip-card"
|
||||
>
|
||||
<div class="card-top">
|
||||
<div class="card-meta">
|
||||
<span class="match-name">{{ item.matchName }}</span>
|
||||
<span class="card-market">{{ item.marketName }}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button type="button" class="del-btn" @click="removeItem(item.selectionId)">✕</button>
|
||||
<button
|
||||
type="button"
|
||||
class="locate-btn"
|
||||
:title="t('bet.slip_locate')"
|
||||
:aria-label="t('bet.slip_locate_aria')"
|
||||
@click="locateSlipItem(item)"
|
||||
>
|
||||
<svg class="locate-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1 1 12 6a2.5 2.5 0 0 1 0 5.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-selection-row">
|
||||
<span class="selection-name">{{ item.selectionName }}</span>
|
||||
<span class="odds-value">
|
||||
<template v-if="oddsDeltaFor(item.selectionId)">
|
||||
<span class="odds-change" :class="oddsTrendClass(oddsDeltaFor(item.selectionId)!)">
|
||||
{{ oddsDeltaFor(item.selectionId)!.oldOdds.toFixed(2) }} →
|
||||
{{ oddsDeltaFor(item.selectionId)!.newOdds.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ item.odds.toFixed(2) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'single'" class="card-stake-block">
|
||||
<div class="card-stake-inline">
|
||||
<div class="card-stake-input-box">
|
||||
<input
|
||||
:value="itemStakeInputValue(item.selectionId)"
|
||||
type="number"
|
||||
class="card-stake-input"
|
||||
:placeholder="t('bet.stake_placeholder') || '金额'"
|
||||
@input="onItemStakeInput(item.selectionId, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="itemStakeInputValue(item.selectionId)"
|
||||
type="button"
|
||||
class="clear-input-btn"
|
||||
@click="clearItemStakeInput(item.selectionId)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="chip-btn mini" @click="addItemStake(item.selectionId, 50)">+50</button>
|
||||
<button type="button" class="chip-btn mini" @click="addItemStake(item.selectionId, 100)">+100</button>
|
||||
<button type="button" class="chip-btn mini" @click="setItemMaxStake(item.selectionId)">
|
||||
{{ t('bet.stake_max') || '全' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="card-action-row"
|
||||
:class="{
|
||||
'card-action-row--single': item.allowParlay === false || item.allowSingle === false,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-if="item.allowSingle !== false"
|
||||
type="button"
|
||||
class="card-action-btn card-single-btn"
|
||||
:disabled="loading || !canPlaceSingleItem(item)"
|
||||
@click="onPlaceSingleItemClick(item)"
|
||||
>
|
||||
{{ t('bet.slip_place_single') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="item.allowParlay !== false"
|
||||
type="button"
|
||||
class="card-action-btn card-parlay-btn"
|
||||
:class="{ active: isItemInParlay(item) }"
|
||||
@click="addItemToParlay(item)"
|
||||
>
|
||||
<span class="card-parlay-btn-icon" aria-hidden="true">{{ isItemInParlay(item) ? '✓' : '+' }}</span>
|
||||
<span>{{ isItemInParlay(item) ? t('bet.slip_parlay_added_btn') : t('bet.add_to_parlay') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-est-row">
|
||||
<span class="est-lbl">{{ t('bet.slip_est_return') }}</span>
|
||||
<span class="est-val">{{ formatMoney(itemEstReturn(item), locale) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeTab === 'parlay' && slip.parlayItems.length" class="parlay-summary">
|
||||
<span>{{ t('bet.slip_parlay_count', { n: slip.parlayItems.length }) }}</span>
|
||||
<span class="parlay-odds">{{ t('bet.slip_parlay_odds', { odds: totalOddsText }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stake input (parlay only) -->
|
||||
<div v-if="activeTab === 'parlay' && activeItems.length" class="stake-control">
|
||||
<div class="stake-input-box">
|
||||
<span class="currency">{{ t('bet.slip_currency') || 'CNY' }}</span>
|
||||
<input
|
||||
v-model="stakeInput"
|
||||
type="number"
|
||||
class="stake-input"
|
||||
:placeholder="t('bet.stake') || '输入金额'"
|
||||
@input="onStakeInput"
|
||||
/>
|
||||
<button v-if="stakeInput" type="button" class="clear-input-btn" @click="clearStakeInput">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="quick-chips">
|
||||
<button type="button" class="chip-btn" @click="addStake(50)">+50</button>
|
||||
<button type="button" class="chip-btn" @click="addStake(100)">+100</button>
|
||||
<button type="button" class="chip-btn" @click="addStake(500)">+500</button>
|
||||
<button type="button" class="chip-btn" @click="setMaxStake">{{ t('bet.stake_max') || '最大' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="panel-error-msg">{{ error }}</p>
|
||||
<p v-if="success" class="panel-success-msg">{{ success }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'history'" class="panel-bottom-fixed panel-bottom-history">
|
||||
<RouterLink to="/bets" class="history-view-all-btn">
|
||||
{{ t('bet.slip_history_view_all') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeItems.length" class="panel-bottom-fixed">
|
||||
<div class="panel-summary">
|
||||
<div class="summary-row">
|
||||
<span class="lbl">{{ t('bet.slip_total_stake') }}</span>
|
||||
<span class="val">{{ stakeText }}</span>
|
||||
</div>
|
||||
<div class="summary-row est-return">
|
||||
<span class="lbl">{{ t('bet.slip_est_return') }}</span>
|
||||
<span class="val">{{ estimatedReturnText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-foot">
|
||||
<button type="button" class="clear-slip-btn foot-clear" @click="clearActiveSlip">
|
||||
{{ t('bet.slip_clear') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="submit-btn"
|
||||
:disabled="loading || !canSubmitWithOdds"
|
||||
@click="onPlaceBetClick"
|
||||
>
|
||||
{{ submitButtonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="showPlaceConfirm"
|
||||
:title="t('bet.place_confirm_title')"
|
||||
:message="placeConfirmMessage"
|
||||
:confirm-text="t('bet.place_bet')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:loading="loading"
|
||||
@confirm="confirmPlaceBet"
|
||||
/>
|
||||
|
||||
<!-- Success Overlay -->
|
||||
<BetSuccessOverlay :show="showSuccess" @done="onSuccessDone" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.betslip-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(16, 16, 16, 0.95);
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-head h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.clear-slip-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.clear-slip-btn.foot-clear {
|
||||
flex: 0 0 20%;
|
||||
min-width: 0;
|
||||
padding: 10px 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clear-slip-btn:hover {
|
||||
color: var(--primary-light);
|
||||
border-color: var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
}
|
||||
|
||||
.balance-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.balance-info .lbl {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.balance-info .val {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: transparent;
|
||||
color: var(--primary-light);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--primary-light);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--primary);
|
||||
color: #111;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
padding: 1px 4px;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.odds-warning {
|
||||
background: rgba(212, 175, 55, 0.12);
|
||||
border-bottom: 1px solid var(--border-gold-soft);
|
||||
color: var(--primary-light);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60%;
|
||||
color: var(--text-muted);
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.slip-card {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 7px 8px;
|
||||
position: relative;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.slip-card:hover {
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.match-name {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-market {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.del-btn {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.locate-btn {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.locate-btn:hover {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.locate-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.del-btn:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.card-selection-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.selection-name {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.odds-value {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.odds-change {
|
||||
font-size: 11px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.odds-up {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.odds-down {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.odds-suspended {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.card-stake-block {
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card-stake-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card-stake-input-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
height: 26px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-stake-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none !important;
|
||||
color: #fff;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-stake-input::-webkit-outer-spin-button,
|
||||
.card-stake-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chip-btn.mini {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
height: 26px;
|
||||
font-size: 9px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.card-action-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.card-action-row--single .card-action-btn {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.card-action-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.card-action-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-single-btn {
|
||||
border: 1px solid rgba(212, 175, 55, 0.55);
|
||||
background: rgba(212, 175, 55, 0.14);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.card-single-btn:hover:not(:disabled) {
|
||||
background: rgba(212, 175, 55, 0.22);
|
||||
border-color: var(--border-gold);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-parlay-btn {
|
||||
border: 1px dashed rgba(212, 175, 55, 0.45);
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.card-parlay-btn:hover:not(:disabled) {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border-color: var(--border-gold-soft);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-parlay-btn.active {
|
||||
border-style: solid;
|
||||
border-color: rgba(212, 175, 55, 0.35);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-parlay-btn.active:hover {
|
||||
color: var(--primary-light);
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.card-parlay-btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: rgba(212, 175, 55, 0.16);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.card-parlay-btn.active .card-parlay-btn-icon {
|
||||
background: rgba(46, 204, 113, 0.18);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.card-est-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.card-est-row .est-lbl {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-est-row .est-val {
|
||||
color: var(--primary-light);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.panel-warning {
|
||||
font-size: 11px;
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.parlay-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 6px;
|
||||
background: #141414;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.parlay-odds {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.stake-control {
|
||||
background: #141414;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stake-input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0 10px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.currency {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-light);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.stake-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none !important;
|
||||
color: #fff;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stake-input::-webkit-outer-spin-button,
|
||||
.stake-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.clear-input-btn {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.quick-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip-btn {
|
||||
flex: 1;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 5px 0;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chip-btn:hover {
|
||||
border-color: var(--border-gold-soft);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.panel-error-msg {
|
||||
color: var(--danger);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.panel-success-msg {
|
||||
color: #2ecc71;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.panel-bottom-fixed {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(16, 16, 16, 0.98);
|
||||
}
|
||||
|
||||
.panel-summary {
|
||||
padding: 10px 16px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.summary-row .lbl {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.summary-row .val {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-row.est-return .val {
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
padding: 10px 16px 12px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.add-parlay-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: transparent;
|
||||
color: var(--primary-light);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-parlay-btn:hover {
|
||||
background: rgba(212, 175, 55, 0.05);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #F0D875 0%, #D4AF37 100%);
|
||||
color: #3D2800;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
transition: opacity 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
filter: saturate(0.5);
|
||||
}
|
||||
|
||||
.history-view-all-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.history-view-all-btn:hover {
|
||||
background: rgba(212, 175, 55, 0.14);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.panel-bottom-history {
|
||||
padding: 10px 16px 12px;
|
||||
}
|
||||
|
||||
.history-scope-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.scope-chip.active {
|
||||
color: var(--primary-light);
|
||||
border-color: var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.history-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
display: block;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: #1e1e1e;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.history-card:hover {
|
||||
border-color: var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
}
|
||||
|
||||
.history-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-pick {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.35;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.history-status.hist-won {
|
||||
color: #2ecc71;
|
||||
background: rgba(46, 204, 113, 0.12);
|
||||
}
|
||||
|
||||
.history-status.hist-lost {
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.12);
|
||||
}
|
||||
|
||||
.history-status.hist-push {
|
||||
color: #95a5a6;
|
||||
background: rgba(149, 165, 166, 0.12);
|
||||
}
|
||||
|
||||
.history-status.hist-pending {
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.history-match {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-card-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.history-stake {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.history-return {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.history-return.hist-won {
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.history-return.hist-lost {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.history-return.hist-push,
|
||||
.history-return.hist-pending {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.history-load-more {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--primary-light);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-load-more:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
123
apps/player/src/components/desktop/DataTable.vue
Normal file
123
apps/player/src/components/desktop/DataTable.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps<{
|
||||
headers: { key: string; label: string; align?: 'left' | 'right' | 'center' }[];
|
||||
items: any[];
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="h in headers"
|
||||
:key="h.key"
|
||||
:class="[`align-${h.align || 'left'}`]"
|
||||
>
|
||||
{{ h.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading && !items.length">
|
||||
<td :colspan="headers.length" class="loading-cell">
|
||||
<div class="skeleton-row desktop-skeleton" v-for="i in 3" :key="i"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="!items.length">
|
||||
<td :colspan="headers.length" class="empty-cell">
|
||||
{{ t('common.no_data') || '暂无数据' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-else
|
||||
v-for="(item, idx) in items"
|
||||
:key="item.id || idx"
|
||||
class="table-row hover-bg"
|
||||
>
|
||||
<td
|
||||
v-for="h in headers"
|
||||
:key="h.key"
|
||||
:class="[`align-${h.align || 'left'}`]"
|
||||
>
|
||||
<slot :name="h.key" :item="item" :index="idx">
|
||||
{{ item[h.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.data-table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #141414;
|
||||
color: var(--text-muted);
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-row:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.align-left { text-align: left; }
|
||||
.align-right { text-align: right; }
|
||||
.align-center { text-align: center; }
|
||||
|
||||
.loading-cell {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
height: 28px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
468
apps/player/src/components/desktop/DesktopBanner3DCarousel.vue
Normal file
468
apps/player/src/components/desktop/DesktopBanner3DCarousel.vue
Normal file
@@ -0,0 +1,468 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { BannerItem } from '../BannerCarousel.vue';
|
||||
import defaultBannerImg from '../../assets/images/banner.webp';
|
||||
|
||||
const props = defineProps<{
|
||||
banners: BannerItem[];
|
||||
fallbackTo?: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const active = ref(0);
|
||||
const timer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
|
||||
|
||||
const slideCount = computed(() => props.banners.length);
|
||||
const canLoop = computed(() => slideCount.value > 1);
|
||||
|
||||
function imageUrl(banner: BannerItem) {
|
||||
return banner.translation?.imageUrl || defaultBannerImg || FALLBACK_IMG;
|
||||
}
|
||||
|
||||
function onImgError(e: Event) {
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (img.dataset.fallbackApplied) return;
|
||||
img.dataset.fallbackApplied = '1';
|
||||
img.src = defaultBannerImg || FALLBACK_IMG;
|
||||
}
|
||||
|
||||
function title(banner: BannerItem) {
|
||||
return banner.translation?.title || t('home.banner_fallback');
|
||||
}
|
||||
|
||||
function normalizeOffset(index: number) {
|
||||
const len = slideCount.value;
|
||||
if (!len) return 0;
|
||||
let offset = index - active.value;
|
||||
if (offset > len / 2) offset -= len;
|
||||
if (offset < -len / 2) offset += len;
|
||||
return offset;
|
||||
}
|
||||
|
||||
/** Coverflow:略重叠,中间卡片置顶 */
|
||||
function slideLayout(index: number) {
|
||||
const offset = normalizeOffset(index);
|
||||
const abs = Math.abs(offset);
|
||||
|
||||
if (abs > 1) {
|
||||
return { hidden: true, offset, active: false };
|
||||
}
|
||||
|
||||
const slotX = 50 + offset * 26;
|
||||
const scale = offset === 0 ? 1 : 0.9;
|
||||
const rotateY = offset === 0 ? 0 : offset < 0 ? 12 : -12;
|
||||
const zIndex = offset === 0 ? 10 : 5;
|
||||
|
||||
return {
|
||||
hidden: false,
|
||||
offset,
|
||||
active: offset === 0,
|
||||
slotX,
|
||||
scale,
|
||||
rotateY,
|
||||
zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function slideStyle(index: number) {
|
||||
const layout = slideLayout(index);
|
||||
if (layout.hidden) {
|
||||
return {
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%) scale(0.7)',
|
||||
opacity: '0',
|
||||
zIndex: '0',
|
||||
pointerEvents: 'none' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${layout.slotX}%`,
|
||||
transform: `translate(-50%, -50%) scale(${layout.scale}) rotateY(${layout.rotateY}deg)`,
|
||||
opacity: '1',
|
||||
zIndex: String(layout.zIndex),
|
||||
pointerEvents: 'auto' as const,
|
||||
};
|
||||
}
|
||||
|
||||
function goTo(index: number) {
|
||||
if (!slideCount.value) return;
|
||||
active.value = (index + slideCount.value) % slideCount.value;
|
||||
}
|
||||
|
||||
function next() {
|
||||
goTo(active.value + 1);
|
||||
}
|
||||
|
||||
function prev() {
|
||||
goTo(active.value - 1);
|
||||
}
|
||||
|
||||
function onBannerClick(banner: BannerItem, index: number) {
|
||||
if (normalizeOffset(index) !== 0) {
|
||||
goTo(index);
|
||||
return;
|
||||
}
|
||||
if (banner.id) {
|
||||
void router.push(`/announcements/${banner.id}`);
|
||||
return;
|
||||
}
|
||||
if (props.fallbackTo) {
|
||||
void router.push(props.fallbackTo);
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoPlay() {
|
||||
stopAutoPlay();
|
||||
if (!canLoop.value) return;
|
||||
timer.value = setInterval(next, 5000);
|
||||
}
|
||||
|
||||
function stopAutoPlay() {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.banners.length,
|
||||
() => {
|
||||
active.value = 0;
|
||||
startAutoPlay();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(startAutoPlay);
|
||||
onUnmounted(stopAutoPlay);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="banners.length"
|
||||
class="banner-coverflow"
|
||||
@mouseenter="stopAutoPlay"
|
||||
@mouseleave="startAutoPlay"
|
||||
>
|
||||
<div class="coverflow-stage">
|
||||
<button
|
||||
v-for="(banner, i) in banners"
|
||||
:key="banner.id ?? i"
|
||||
type="button"
|
||||
class="coverflow-slide"
|
||||
:class="{ 'is-active': slideLayout(i).active, 'is-side': Math.abs(slideLayout(i).offset) === 1 }"
|
||||
:style="slideStyle(i)"
|
||||
:aria-label="title(banner)"
|
||||
@click="onBannerClick(banner, i)"
|
||||
>
|
||||
<div class="slide-card">
|
||||
<img
|
||||
v-if="imageUrl(banner)"
|
||||
:src="imageUrl(banner)"
|
||||
:alt="''"
|
||||
class="slide-img"
|
||||
:loading="i === 0 ? 'eager' : 'lazy'"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<div v-else class="slide-fallback" aria-hidden="true">
|
||||
<svg class="fallback-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-if="slideLayout(i).active" class="click-hint" aria-hidden="true">
|
||||
<span class="click-ring" />
|
||||
<svg class="click-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 11.24V7.5a2.5 2.5 0 0 1 5 0v3.74c1.21-.81 2-2.18 2-3.74C16 5.01 13.99 3 11.5 3S7 5.01 7 7.5c0 1.56.79 2.93 2 3.74zm9.84 4.63-4.54-2.26a1.5 1.5 0 0 0-.54-.11H13v-6c0-.83-.67-1.5-1.5-1.5S10 6.67 10 7.5v10.74l-3.43-.72a1 1 0 0 0-.24-.03c-.31 0-.59.13-.79.33l-.79.8 4.94 4.94c.27.27.65.44 1.06.44h6.79c.75 0 1.33-.55 1.44-1.28l.75-5.27a1.5 1.5 0 0 0-.91-1.38z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canLoop"
|
||||
type="button"
|
||||
class="nav-btn prev"
|
||||
:aria-label="t('home.banner_prev')"
|
||||
@click.stop="prev"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="canLoop"
|
||||
type="button"
|
||||
class="nav-btn next"
|
||||
:aria-label="t('home.banner_next')"
|
||||
@click.stop="next"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="canLoop" class="coverflow-dots">
|
||||
<button
|
||||
v-for="(_, i) in banners"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="dot"
|
||||
:class="{ active: i === active }"
|
||||
:aria-label="t('home.banner_slide', { n: i + 1 })"
|
||||
@click.stop="goTo(i)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.banner-coverflow {
|
||||
width: 100%;
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
.coverflow-stage {
|
||||
position: relative;
|
||||
height: clamp(260px, 28vw, 360px);
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.coverflow-slide {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 42%;
|
||||
max-width: 560px;
|
||||
height: 90%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
left 0.55s cubic-bezier(0.25, 0.8, 0.25, 1),
|
||||
transform 0.55s cubic-bezier(0.25, 0.8, 0.25, 1),
|
||||
opacity 0.4s ease;
|
||||
transform-style: preserve-3d;
|
||||
will-change: left, transform;
|
||||
}
|
||||
|
||||
.slide-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #141414 0%, #0a0a0a 100%);
|
||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.45);
|
||||
transition: box-shadow 0.35s ease, filter 0.35s ease, transform 0.25s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.coverflow-slide.is-side .slide-card {
|
||||
filter: brightness(0.72) saturate(0.9);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.coverflow-slide.is-side:hover .slide-card {
|
||||
filter: brightness(0.88) saturate(1);
|
||||
}
|
||||
|
||||
.coverflow-slide.is-active .slide-card {
|
||||
filter: none;
|
||||
box-shadow:
|
||||
0 16px 40px rgba(0, 0, 0, 0.5),
|
||||
0 0 28px rgba(212, 175, 55, 0.22),
|
||||
0 0 56px rgba(212, 175, 55, 0.1);
|
||||
animation: banner-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.coverflow-slide.is-active:hover .slide-card {
|
||||
animation: none;
|
||||
transform: scale(1.018);
|
||||
}
|
||||
|
||||
@keyframes banner-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(0, 0, 0, 0.5),
|
||||
0 0 22px rgba(212, 175, 55, 0.16),
|
||||
0 0 48px rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.008);
|
||||
box-shadow:
|
||||
0 18px 44px rgba(0, 0, 0, 0.52),
|
||||
0 0 32px rgba(212, 175, 55, 0.26),
|
||||
0 0 60px rgba(212, 175, 55, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(212, 175, 55, 0.12), #1a1a1a);
|
||||
}
|
||||
|
||||
.fallback-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: rgba(212, 175, 55, 0.35);
|
||||
}
|
||||
|
||||
.click-hint {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.click-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(212, 175, 55, 0.45);
|
||||
animation: click-ring-pulse 2.8s ease-out infinite;
|
||||
}
|
||||
|
||||
.click-icon {
|
||||
position: relative;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: rgba(212, 175, 55, 0.88);
|
||||
animation: click-icon-bob 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes click-ring-pulse {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1.35);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.35);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes click-icon-bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(1px) scale(1);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-1px) scale(1.06);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.coverflow-slide.is-active:hover .click-hint {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.coverflow-slide.is-active .slide-card,
|
||||
.click-ring,
|
||||
.click-icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(212, 175, 55, 0.4);
|
||||
background: rgba(10, 10, 10, 0.75);
|
||||
color: var(--primary-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.nav-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: rgba(212, 175, 55, 0.2);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-btn.prev {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.nav-btn.next {
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.coverflow-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
width: 22px;
|
||||
border-radius: 4px;
|
||||
background: var(--gradient-gold);
|
||||
}
|
||||
|
||||
.dot:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
</style>
|
||||
400
apps/player/src/components/desktop/DesktopOddsBetPopover.vue
Normal file
400
apps/player/src/components/desktop/DesktopOddsBetPopover.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
|
||||
import { useBetSlipStore, type ParlaySlipError } from '../../stores/betSlip';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { formatMoney } from '../../utils/localeDisplay';
|
||||
import api from '../../api';
|
||||
import { usePlayerProfile } from '../../composables/usePlayerProfile';
|
||||
import ConfirmDialog from '../ConfirmDialog.vue';
|
||||
import { buildBetPlaceConfirmMessage } from '../../utils/betPlaceConfirmMessage';
|
||||
import { useAppToast } from '../../composables/useAppToast';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { visible, anchorX, anchorY, pendingItem, close } = useDesktopBetPopover();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
const { refreshProfile } = usePlayerProfile();
|
||||
const { showToast } = useAppToast();
|
||||
|
||||
const popRef = ref<HTMLElement | null>(null);
|
||||
const stakeInput = ref('5');
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const showPlaceConfirm = ref(false);
|
||||
const placeConfirmMessage = ref('');
|
||||
const MIN_STAKE = 5;
|
||||
|
||||
let outsideClickTimer = 0;
|
||||
|
||||
const stake = computed(() => {
|
||||
const n = Number.parseFloat(stakeInput.value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
});
|
||||
|
||||
const estReturn = computed(() => {
|
||||
if (!pendingItem.value || stake.value <= 0) return 0;
|
||||
return stake.value * pendingItem.value.odds;
|
||||
});
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (open) {
|
||||
error.value = '';
|
||||
stakeInput.value = String(Math.max(MIN_STAKE, slip.stake || MIN_STAKE));
|
||||
window.clearTimeout(outsideClickTimer);
|
||||
outsideClickTimer = window.setTimeout(() => {
|
||||
document.addEventListener('click', onDocumentClick, true);
|
||||
}, 0);
|
||||
} else {
|
||||
document.removeEventListener('click', onDocumentClick, true);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(outsideClickTimer);
|
||||
document.removeEventListener('click', onDocumentClick, true);
|
||||
});
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!visible.value || !popRef.value || showPlaceConfirm.value) return;
|
||||
const target = event.target as Node;
|
||||
if (target instanceof Element && target.closest('.confirm-overlay')) return;
|
||||
if (!popRef.value.contains(target)) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function parlayErrorMessage(reason: ParlaySlipError) {
|
||||
if (reason === 'MAX_LEGS') return t('bet.parlay_max_legs');
|
||||
if (reason === 'QUARTER_LINE') return t('bet.parlay_block_quarter');
|
||||
if (reason === 'OUTRIGHT') return t('bet.parlay_block_outright');
|
||||
if (reason === 'NOT_ALLOWED') return t('bet.parlay_block_not_allowed');
|
||||
if (reason === 'SAME_MATCH') return t('bet.slip_parlay_same_match');
|
||||
return t('bet.parlay_block_not_allowed');
|
||||
}
|
||||
|
||||
function validatePlaceNow(): boolean {
|
||||
const item = pendingItem.value;
|
||||
if (!item) return false;
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt();
|
||||
return false;
|
||||
}
|
||||
if (item.allowSingle === false) {
|
||||
error.value = t('bet.slip_parlay_only_hint');
|
||||
return false;
|
||||
}
|
||||
if (stake.value < MIN_STAKE) {
|
||||
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function onPlaceNowClick() {
|
||||
if (!validatePlaceNow()) return;
|
||||
const item = pendingItem.value!;
|
||||
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
|
||||
mode: 'single',
|
||||
items: [item],
|
||||
totalStake: stake.value,
|
||||
totalReturn: estReturn.value,
|
||||
formatMoney: (amount) => formatMoney(amount, locale.value),
|
||||
getStake: () => stake.value,
|
||||
getOdds: (row) => row.odds,
|
||||
});
|
||||
showPlaceConfirm.value = true;
|
||||
}
|
||||
|
||||
async function executePlaceNow(item = pendingItem.value) {
|
||||
if (!item) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
stake: stake.value,
|
||||
requestId: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
});
|
||||
slip.stake = stake.value;
|
||||
await refreshProfile();
|
||||
showPlaceConfirm.value = false;
|
||||
close();
|
||||
showToast(t('bet.place_success'));
|
||||
} catch (e: unknown) {
|
||||
error.value =
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||
t('bet.place_failed');
|
||||
showToast(error.value);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPlaceNow() {
|
||||
const item = pendingItem.value;
|
||||
if (!item) return;
|
||||
showPlaceConfirm.value = false;
|
||||
await executePlaceNow(item);
|
||||
}
|
||||
|
||||
function addToSingleList() {
|
||||
const item = pendingItem.value;
|
||||
if (!item) return;
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt();
|
||||
return;
|
||||
}
|
||||
if (item.allowSingle === false) {
|
||||
error.value = t('bet.slip_parlay_only_hint');
|
||||
return;
|
||||
}
|
||||
slip.stake = Math.max(MIN_STAKE, stake.value || MIN_STAKE);
|
||||
const before = slip.singleCartItems.length;
|
||||
slip.addToSingleCart(item);
|
||||
close();
|
||||
showToast(t(before < slip.singleCartItems.length ? 'bet.added_to_single' : 'bet.pick_added'));
|
||||
}
|
||||
|
||||
function addToParlayList() {
|
||||
const item = pendingItem.value;
|
||||
if (!item) return;
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt();
|
||||
return;
|
||||
}
|
||||
slip.stake = Math.max(MIN_STAKE, stake.value || MIN_STAKE);
|
||||
const before = slip.parlayItems.length;
|
||||
const err = slip.addParlayLeg(item);
|
||||
if (err) {
|
||||
error.value = parlayErrorMessage(err);
|
||||
showToast(error.value);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
showToast(t(before < slip.parlayItems.length ? 'bet.added_to_parlay' : 'bet.pick_added'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible && pendingItem"
|
||||
ref="popRef"
|
||||
class="bet-popover"
|
||||
tabindex="-1"
|
||||
:style="{ left: `${anchorX}px`, top: `${anchorY}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" class="pop-close" aria-label="Close" @click="close">✕</button>
|
||||
|
||||
<div class="pop-match">{{ pendingItem.matchName }}</div>
|
||||
<div class="pop-market">{{ pendingItem.marketName }}</div>
|
||||
<div class="pop-pick-row">
|
||||
<span class="pick">{{ pendingItem.selectionName }}</span>
|
||||
<span class="odds">@ {{ pendingItem.odds.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<label class="stake-label">{{ t('bet.stake') }}</label>
|
||||
<input v-model="stakeInput" type="number" class="stake-input" min="5" step="1" />
|
||||
|
||||
<div class="est-row">
|
||||
<span>{{ t('bet.slip_est_return') }}</span>
|
||||
<span class="est-val">{{ formatMoney(estReturn, locale) }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="pop-error">{{ error }}</p>
|
||||
|
||||
<div class="pop-actions">
|
||||
<button type="button" class="btn-primary-gold" :disabled="loading" @click="onPlaceNowClick">
|
||||
{{ loading ? t('bet.placing') : t('bet.place_now') }}
|
||||
</button>
|
||||
<div class="pop-actions-row">
|
||||
<button type="button" class="btn-outline" @click="addToSingleList">
|
||||
{{ t('bet.add_to_single') }}
|
||||
</button>
|
||||
<button type="button" class="btn-outline" @click="addToParlayList">
|
||||
{{ t('bet.add_to_parlay') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="showPlaceConfirm"
|
||||
:title="t('bet.place_confirm_title')"
|
||||
:message="placeConfirmMessage"
|
||||
:confirm-text="t('bet.place_bet')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:loading="loading"
|
||||
@confirm="confirmPlaceNow"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-popover {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 280px;
|
||||
padding: 12px 28px 12px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(18, 18, 18, 0.98);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.pop-close {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.pop-close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pop-match {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pop-market {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pop-pick-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.pick {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.odds {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.stake-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stake-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0d0d0d;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stake-input:focus {
|
||||
border-color: var(--border-gold-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.est-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.est-val {
|
||||
color: var(--primary-light);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pop-error {
|
||||
font-size: 10px;
|
||||
color: var(--danger);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.pop-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pop-actions-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pop-actions-row .btn-outline {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-primary-gold {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #f0d875 0%, #d4af37 100%);
|
||||
color: #3d2800;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary-gold:hover:not(:disabled) {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.btn-primary-gold:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
width: 100%;
|
||||
padding: 7px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.05);
|
||||
color: var(--primary-light);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: rgba(212, 175, 55, 0.12);
|
||||
}
|
||||
</style>
|
||||
112
apps/player/src/components/desktop/DesktopOutrightEventCard.vue
Normal file
112
apps/player/src/components/desktop/DesktopOutrightEventCard.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { OutrightEvent } from '../outright/OutrightEventSection.vue';
|
||||
import saishiImg from '../../assets/images/saishi.webp';
|
||||
|
||||
const props = defineProps<{
|
||||
event: OutrightEvent;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ open: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const headTitle = computed(() => {
|
||||
const raw = props.event.title.replace(/^\*+/, '').trim();
|
||||
return raw || props.event.leagueName || t('bet.tab_outright');
|
||||
});
|
||||
|
||||
const teamCount = computed(() => props.event.selectionCount ?? props.event.selections.length);
|
||||
|
||||
const isSettled = computed(
|
||||
() => props.event.bettingOpen === false || props.event.status === 'SETTLED',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="outright-event-card hover-bg" @click="emit('open')">
|
||||
<div class="card-main">
|
||||
<div class="title-row">
|
||||
<span class="title">{{ headTitle }}</span>
|
||||
<span v-if="isSettled" class="settled-tag">{{ t('bet.outright_settled') }}</span>
|
||||
</div>
|
||||
<p v-if="event.leagueName && event.leagueName !== headTitle" class="league">{{ event.leagueName }}</p>
|
||||
<p class="meta">{{ t('bet.outright_teams_count', { n: teamCount }) }}</p>
|
||||
</div>
|
||||
<img :src="saishiImg" alt="" class="saishi" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outright-event-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.outright-event-card:hover {
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.card-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.settled-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: #c9a227;
|
||||
border: 1px solid rgba(201, 162, 39, 0.45);
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
.league {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.saishi {
|
||||
flex-shrink: 0;
|
||||
height: 44px;
|
||||
width: auto;
|
||||
max-width: 40px;
|
||||
object-fit: contain;
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
131
apps/player/src/components/desktop/DesktopShell.vue
Normal file
131
apps/player/src/components/desktop/DesktopShell.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import { useRoute, RouterView } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import DesktopTopNav from './DesktopTopNav.vue';
|
||||
import DesktopOddsBetPopover from './DesktopOddsBetPopover.vue';
|
||||
import SportsCategoryBar from './SportsCategoryBar.vue';
|
||||
import LeagueSidebar from './LeagueSidebar.vue';
|
||||
import MatchSidebar from './MatchSidebar.vue';
|
||||
import BetSlipPanel from './BetSlipPanel.vue';
|
||||
import FloatingMailbox from '../FloatingMailbox.vue';
|
||||
import { usePlayerHome } from '../../composables/usePlayerHome';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const { locale } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { load: loadPlayerHome } = usePlayerHome();
|
||||
|
||||
const layoutMode = computed<'betting' | 'account' | 'hub' | 'marketing'>(() => {
|
||||
const p = route.path;
|
||||
if (p === '/bet' || p.startsWith('/match/') || p.startsWith('/outright/')) {
|
||||
return 'betting';
|
||||
}
|
||||
if (
|
||||
p === '/bets' ||
|
||||
p.startsWith('/bets/') ||
|
||||
p.startsWith('/wallet') ||
|
||||
p === '/profile/cashbacks'
|
||||
) {
|
||||
return 'account';
|
||||
}
|
||||
if (p.startsWith('/announcements')) {
|
||||
return 'hub';
|
||||
}
|
||||
if (p.startsWith('/messages')) {
|
||||
return 'hub';
|
||||
}
|
||||
return 'marketing';
|
||||
});
|
||||
|
||||
const isMatchDetail = computed(() => route.path.startsWith('/match/'));
|
||||
const isBettingDetail = computed(
|
||||
() => route.path.startsWith('/match/') || route.path.startsWith('/outright/'),
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void loadPlayerHome(true);
|
||||
});
|
||||
|
||||
watch(locale, (next, prev) => {
|
||||
if (prev && next !== prev) void loadPlayerHome(true);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => auth.token,
|
||||
() => {
|
||||
void loadPlayerHome(true);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop-shell">
|
||||
<header class="desktop-header-wrap">
|
||||
<DesktopTopNav />
|
||||
</header>
|
||||
|
||||
<div class="desktop-main-container">
|
||||
<!-- Betting Layout -->
|
||||
<div v-if="layoutMode === 'betting'" class="desktop-layout-betting-wrap">
|
||||
<div class="desktop-betting-sports-row">
|
||||
<SportsCategoryBar />
|
||||
</div>
|
||||
<div class="desktop-layout-betting">
|
||||
<aside class="desktop-betting-left">
|
||||
<LeagueSidebar v-if="!isMatchDetail" />
|
||||
<MatchSidebar v-else />
|
||||
</aside>
|
||||
<main class="desktop-betting-center" :class="{ 'is-betting-detail': isBettingDetail }">
|
||||
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
|
||||
<component :is="Component" :key="viewRoute.path" />
|
||||
</KeepAlive>
|
||||
<component v-else :is="Component" :key="viewRoute.fullPath" />
|
||||
</RouterView>
|
||||
</main>
|
||||
<aside class="desktop-betting-right">
|
||||
<BetSlipPanel />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Layout -->
|
||||
<div v-else-if="layoutMode === 'account'" class="desktop-layout-account">
|
||||
<main class="desktop-account-center">
|
||||
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
|
||||
<component :is="Component" :key="viewRoute.path" />
|
||||
</KeepAlive>
|
||||
<component v-else :is="Component" :key="viewRoute.fullPath" />
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Hub Layout: Messages / Announcements split pane -->
|
||||
<div v-else-if="layoutMode === 'hub'" class="desktop-layout-hub-outer">
|
||||
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
|
||||
<component :is="Component" :key="viewRoute.path" />
|
||||
</KeepAlive>
|
||||
<component v-else :is="Component" :key="viewRoute.fullPath" />
|
||||
</RouterView>
|
||||
</div>
|
||||
|
||||
<!-- Marketing/Single Column Layout -->
|
||||
<div v-else class="desktop-layout-marketing">
|
||||
<main class="desktop-marketing-content">
|
||||
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
|
||||
<component :is="Component" :key="viewRoute.path" />
|
||||
</KeepAlive>
|
||||
<component v-else :is="Component" :key="viewRoute.fullPath" />
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<FloatingMailbox />
|
||||
<DesktopOddsBetPopover />
|
||||
</div>
|
||||
</template>
|
||||
243
apps/player/src/components/desktop/DesktopTopNav.vue
Normal file
243
apps/player/src/components/desktop/DesktopTopNav.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import LocaleSwitcher from '../LocaleSwitcher.vue';
|
||||
import CashBalanceChip from '../CashBalanceChip.vue';
|
||||
import UserAvatarMenu from '../UserAvatarMenu.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const activeTab = computed(() => {
|
||||
const p = route.path;
|
||||
if (p === '/') return 'home';
|
||||
if (p === '/bet' || p.startsWith('/match/') || p.startsWith('/outright/')) return 'sports';
|
||||
if (p === '/bets' || p.startsWith('/bets/')) return 'bet_history';
|
||||
if (p.startsWith('/wallet') || p === '/profile/cashbacks') return 'wallet';
|
||||
if (p.startsWith('/profile')) return 'profile';
|
||||
if (p.startsWith('/messages')) return 'messages';
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop-top-nav">
|
||||
<RouterLink to="/" class="logo-wrap">
|
||||
<img src="/logo.png" alt="TheBet365" class="logo" />
|
||||
<span class="site-name">TheBet365</span>
|
||||
</RouterLink>
|
||||
<nav class="nav-menu">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="nav-link"
|
||||
:class="{ active: activeTab === 'home' }"
|
||||
>
|
||||
{{ t('nav.home') }}
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/bet"
|
||||
class="nav-link"
|
||||
:class="{ active: activeTab === 'sports' }"
|
||||
>
|
||||
{{ t('nav.sports') }}
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/bets"
|
||||
class="nav-link"
|
||||
:class="{ active: activeTab === 'bet_history' }"
|
||||
>
|
||||
{{ t('nav.bet_history') }}
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/wallet"
|
||||
class="nav-link"
|
||||
:class="{ active: activeTab === 'wallet' }"
|
||||
>
|
||||
{{ t('nav.wallet') }}
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/profile"
|
||||
class="nav-link"
|
||||
:class="{ active: activeTab === 'profile' }"
|
||||
>
|
||||
{{ t('nav.profile') }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="nav-right">
|
||||
<LocaleSwitcher />
|
||||
<template v-if="auth.user">
|
||||
<CashBalanceChip />
|
||||
<UserAvatarMenu />
|
||||
</template>
|
||||
<div v-else class="auth-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="login-btn"
|
||||
@click="auth.showLoginPrompt(route.fullPath)"
|
||||
>
|
||||
{{ t('auth.login') }}
|
||||
</button>
|
||||
<RouterLink to="/register" class="register-btn">
|
||||
{{ t('auth.register') || '注册' }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-top-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.logo-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 38px;
|
||||
width: auto;
|
||||
display: block;
|
||||
filter: drop-shadow(0 0 4px rgba(212, 175, 55, 0.2));
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--gradient-gold);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 0 8px rgba(212, 175, 55, 0.15);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.logo-wrap:hover .site-name {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
padding: 18px 4px;
|
||||
transition: color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--primary);
|
||||
border-radius: 3px 3px 0 0;
|
||||
box-shadow: 0 -1px 8px rgba(212, 175, 55, 0.5);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.auth-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
padding: 6px 18px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.05);
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: rgba(212, 175, 55, 0.12);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.register-btn {
|
||||
padding: 7px 18px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #F0D875 0%, #D4AF37 100%);
|
||||
color: #3D2800;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.register-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Override locale switcher and other header action height */
|
||||
:deep(.locale-switch:not(.compact)),
|
||||
:deep(.cash-chip),
|
||||
:deep(.avatar-btn) {
|
||||
height: 34px !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
:deep(.avatar-btn) {
|
||||
width: 34px !important;
|
||||
}
|
||||
</style>
|
||||
46
apps/player/src/components/desktop/DesktopWalletSubNav.vue
Normal file
46
apps/player/src/components/desktop/DesktopWalletSubNav.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
path: '/wallet',
|
||||
labelKey: 'nav.wallet',
|
||||
match: (p: string) =>
|
||||
p === '/wallet' ||
|
||||
p === '/wallet/detail' ||
|
||||
p === '/wallet/recharge' ||
|
||||
p.startsWith('/wallet/transactions'),
|
||||
},
|
||||
{
|
||||
path: '/wallet/recharge/history',
|
||||
labelKey: 'wallet.recharge_history',
|
||||
match: (p: string) => p.startsWith('/wallet/recharge/history'),
|
||||
},
|
||||
{
|
||||
path: '/wallet/cashbacks',
|
||||
labelKey: 'wallet.cashbacks_tab',
|
||||
match: (p: string) => p === '/wallet/cashbacks' || p === '/profile/cashbacks',
|
||||
},
|
||||
];
|
||||
|
||||
const activePath = computed(() => route.path);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="desktop-wallet-subnav" :aria-label="t('nav.wallet')">
|
||||
<RouterLink
|
||||
v-for="tab in tabs"
|
||||
:key="tab.path"
|
||||
:to="tab.path"
|
||||
class="desktop-wallet-subnav-link"
|
||||
:class="{ active: tab.match(activePath) }"
|
||||
>
|
||||
{{ t(tab.labelKey) }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
319
apps/player/src/components/desktop/LeagueSidebar.vue
Normal file
319
apps/player/src/components/desktop/LeagueSidebar.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMatchFilters } from '../../composables/useMatchFilters';
|
||||
import { useDesktopParlayMatches } from '../../composables/useDesktopParlayMatches';
|
||||
import {
|
||||
isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow,
|
||||
isInLocalTodayMatchWindow as isInTodayMatchWindow,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { parlayMatches, loadParlayMatches } = useDesktopParlayMatches();
|
||||
const { searchQuery, filterState, toggleLeague, clearFilters } = useMatchFilters();
|
||||
|
||||
onMounted(() => {
|
||||
void loadParlayMatches();
|
||||
});
|
||||
|
||||
function normalizeLeagueName(name: string): string {
|
||||
return name
|
||||
.replace(/\.unit$/i, '')
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const availableLeagues = computed(() => {
|
||||
const map = new Map<string, { id: string; name: string; count: number }>();
|
||||
const now = new Date();
|
||||
const keyword = searchQuery.value.trim().toLowerCase();
|
||||
|
||||
for (const m of parlayMatches.value) {
|
||||
if (keyword) {
|
||||
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
|
||||
if (!haystack.includes(keyword)) continue;
|
||||
}
|
||||
|
||||
let timeMatch = true;
|
||||
if (filterState.value.time === 'today') {
|
||||
timeMatch = isInTodayMatchWindow(m.startTime, now);
|
||||
} else if (filterState.value.time === 'early') {
|
||||
timeMatch = isAfterTodayMatchWindow(m.startTime, now);
|
||||
}
|
||||
if (!timeMatch) continue;
|
||||
|
||||
if (filterState.value.status === 'open' && m.matchPhase !== 'open' && m.matchPhase !== undefined) continue;
|
||||
if (filterState.value.status === 'settled' && m.matchPhase !== 'settled') continue;
|
||||
|
||||
const id = m.leagueId ?? m.leagueName;
|
||||
const existing = map.get(id);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
map.set(id, {
|
||||
id,
|
||||
name: normalizeLeagueName(m.leagueName),
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(leagueId);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="league-sidebar">
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('nav.search')"
|
||||
class="sidebar-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="section-hdr">{{ t('bet.filter_time') }}</div>
|
||||
<div class="time-filters">
|
||||
<button
|
||||
type="button"
|
||||
class="time-btn"
|
||||
:class="{ active: filterState.time === 'all' }"
|
||||
@click="filterState.time = 'all'"
|
||||
>
|
||||
{{ t('bet.time_all') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="time-btn"
|
||||
:class="{ active: filterState.time === 'today' }"
|
||||
@click="filterState.time = 'today'"
|
||||
>
|
||||
{{ t('bet.time_today') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="time-btn"
|
||||
:class="{ active: filterState.time === 'early' }"
|
||||
@click="filterState.time = 'early'"
|
||||
>
|
||||
{{ t('bet.time_early') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section leagues-section">
|
||||
<div class="section-hdr leagues-hdr">
|
||||
<span>{{ t('bet.filter_league') }}</span>
|
||||
<button
|
||||
v-if="filterState.leagueIds.length > 0"
|
||||
type="button"
|
||||
class="clear-btn"
|
||||
@click="clearFilters"
|
||||
>
|
||||
{{ t('common.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="leagues-list">
|
||||
<div
|
||||
v-for="lg in availableLeagues"
|
||||
:key="lg.id"
|
||||
class="league-item"
|
||||
:class="{ active: isSelected(lg.id) }"
|
||||
@click="toggleLeague(lg.id)"
|
||||
>
|
||||
<div class="checkbox" :class="{ checked: isSelected(lg.id) }"></div>
|
||||
<span class="league-name" :title="lg.name">{{ lg.name }}</span>
|
||||
<span class="league-count">{{ lg.count }}</span>
|
||||
</div>
|
||||
<div v-if="availableLeagues.length === 0" class="empty-leagues">
|
||||
{{ t('bet.no_matches') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.league-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
padding: 0 12px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-search-input {
|
||||
width: 100%;
|
||||
background: #0d0d0d !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sidebar-search-input:focus {
|
||||
border-color: var(--border-gold-soft) !important;
|
||||
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.1) !important;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.section-hdr {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.time-filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.time-btn {
|
||||
flex: 1;
|
||||
background: #141414;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 5px 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.time-btn:hover {
|
||||
border-color: var(--border-gold-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.time-btn.active {
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.leagues-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.leagues-hdr {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: transparent;
|
||||
color: var(--primary-light);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.leagues-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-right: -4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.league-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.league-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.league-item.active {
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.checkbox.checked {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.checkbox.checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 4px;
|
||||
width: 3px;
|
||||
height: 6px;
|
||||
border: solid #111;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.league-name {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #ddd;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.league-item.active .league-name {
|
||||
color: var(--primary-light);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.league-count {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
background: #181818;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-leagues {
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
342
apps/player/src/components/desktop/MatchSidebar.vue
Normal file
342
apps/player/src/components/desktop/MatchSidebar.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMatchFilters } from '../../composables/useMatchFilters';
|
||||
import { useDesktopParlayMatches } from '../../composables/useDesktopParlayMatches';
|
||||
import MatchBetCard from '../MatchBetCard.vue';
|
||||
import GoldSpinner from '../GoldSpinner.vue';
|
||||
import {
|
||||
isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow,
|
||||
isInLocalTodayMatchWindow as isInTodayMatchWindow,
|
||||
} from '@thebet365/shared';
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { searchQuery, filterState } = useMatchFilters();
|
||||
const { parlayMatches, parlayLoading, loadParlayMatches } = useDesktopParlayMatches();
|
||||
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
void loadParlayMatches().then(() => {
|
||||
scrollToActive();
|
||||
});
|
||||
});
|
||||
|
||||
const currentMatchId = computed(() => {
|
||||
const id = route.params.id;
|
||||
return Array.isArray(id) ? id[0] : id;
|
||||
});
|
||||
|
||||
function matchesSearchKeyword(m: any, keyword: string) {
|
||||
if (!keyword) return true;
|
||||
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
|
||||
return haystack.includes(keyword);
|
||||
}
|
||||
|
||||
const filteredMatches = computed(() => {
|
||||
const now = new Date();
|
||||
const keyword = searchQuery.value.trim().toLowerCase();
|
||||
return parlayMatches.value.filter((m) => {
|
||||
const isCurrent = String(m.id) === String(currentMatchId.value);
|
||||
|
||||
// 封盘或已结算的不展示,除非是当前正在查看的赛事
|
||||
if (!isCurrent && (m.bettingOpen === false || m.matchPhase === 'settled' || m.matchPhase === 'closed_pending')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isCurrent && !matchesSearchKeyword(m, keyword)) return false;
|
||||
|
||||
let timeMatch = true;
|
||||
if (!isCurrent) {
|
||||
if (filterState.value.time === 'today') {
|
||||
timeMatch = isInTodayMatchWindow(m.startTime, now);
|
||||
} else if (filterState.value.time === 'early') {
|
||||
timeMatch = isAfterTodayMatchWindow(m.startTime, now);
|
||||
}
|
||||
}
|
||||
return isCurrent || timeMatch;
|
||||
});
|
||||
});
|
||||
|
||||
function goMatch(id: string) {
|
||||
router.replace(`/match/${id}`);
|
||||
}
|
||||
|
||||
function scrollToActive() {
|
||||
nextTick(() => {
|
||||
if (!listRef.value) return;
|
||||
const activeEl = listRef.value.querySelector('.active') as HTMLElement;
|
||||
if (activeEl) {
|
||||
activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(currentMatchId, scrollToActive);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="match-sidebar">
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('nav.search')"
|
||||
class="sidebar-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="section-hdr">{{ t('bet.filter_time') }}</div>
|
||||
<div class="time-filters">
|
||||
<button
|
||||
type="button"
|
||||
class="time-btn"
|
||||
:class="{ active: filterState.time === 'all' }"
|
||||
@click="filterState.time = 'all'"
|
||||
>
|
||||
{{ t('bet.time_all') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="time-btn"
|
||||
:class="{ active: filterState.time === 'today' }"
|
||||
@click="filterState.time = 'today'"
|
||||
>
|
||||
{{ t('bet.time_today') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="time-btn"
|
||||
:class="{ active: filterState.time === 'early' }"
|
||||
@click="filterState.time = 'early'"
|
||||
>
|
||||
{{ t('bet.time_early') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section matches-section">
|
||||
<div class="section-hdr leagues-hdr">
|
||||
<span>{{ t('bet.tab_matches') || '赛事' }}</span>
|
||||
</div>
|
||||
<div class="matches-list" ref="listRef">
|
||||
<div v-if="parlayLoading && !filteredMatches.length" class="sidebar-loading">
|
||||
<GoldSpinner :size="24" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<MatchBetCard
|
||||
v-for="m in filteredMatches"
|
||||
:key="m.id"
|
||||
:match="m"
|
||||
:class="{ active: String(m.id) === String(currentMatchId) }"
|
||||
@bet="goMatch"
|
||||
/>
|
||||
<div v-if="filteredMatches.length === 0 && !parlayLoading" class="empty-matches">
|
||||
{{ t('bet.no_matches') }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
padding: 0 12px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-search-input {
|
||||
width: 100%;
|
||||
background: #0d0d0d !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sidebar-search-input:focus {
|
||||
border-color: var(--border-gold-soft) !important;
|
||||
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.1) !important;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.section-hdr {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.leagues-hdr {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.time-filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.time-btn {
|
||||
flex: 1;
|
||||
background: #141414;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 5px 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.time-btn:hover {
|
||||
border-color: var(--border-gold-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.time-btn.active {
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.matches-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.matches-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-right: -4px;
|
||||
padding-right: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.empty-matches {
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Compact overrides for MatchBetCard inside sidebar */
|
||||
.matches-list :deep(.bet-btn),
|
||||
.matches-list :deep(.team-flag) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.matches-list :deep(.team) {
|
||||
transform: none !important;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.matches-list :deep(.team-name) {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
padding: 0 4px;
|
||||
max-width: 80px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.matches-list :deep(.center-col) {
|
||||
min-width: 60px;
|
||||
gap: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.matches-list :deep(.kickoff) {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-shadow: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.matches-list :deep(.vs) {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.matches-list :deep(.live-score) {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.matches-list :deep(.status-tag) {
|
||||
font-size: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 0 4px 0 6px;
|
||||
}
|
||||
|
||||
.matches-list :deep(.match-card) {
|
||||
padding: 10px 8px;
|
||||
min-height: unset;
|
||||
background: #141414;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.matches-list :deep(.match-card:hover) {
|
||||
border-color: var(--border-gold-soft);
|
||||
background: var(--bg-hover) !important;
|
||||
}
|
||||
|
||||
.matches-list :deep(.match-card.active) {
|
||||
border-color: var(--primary) !important;
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
box-shadow: 0 0 8px rgba(212, 175, 55, 0.25);
|
||||
}
|
||||
</style>
|
||||
172
apps/player/src/components/desktop/Pagination.vue
Normal file
172
apps/player/src/components/desktop/Pagination.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Number, required: true },
|
||||
total: { type: Number, required: true },
|
||||
pageSize: { type: Number, default: 20 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:pageSize', 'change']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
|
||||
|
||||
const pages = computed(() => {
|
||||
const current = props.modelValue;
|
||||
const max = totalPages.value;
|
||||
if (max <= 7) {
|
||||
return Array.from({ length: max }, (_, i) => i + 1);
|
||||
}
|
||||
if (current <= 4) {
|
||||
return [1, 2, 3, 4, 5, '...', max];
|
||||
}
|
||||
if (current >= max - 3) {
|
||||
return [1, '...', max - 4, max - 3, max - 2, max - 1, max];
|
||||
}
|
||||
return [1, '...', current - 1, current, current + 1, '...', max];
|
||||
});
|
||||
|
||||
function goTo(page: number | string) {
|
||||
if (typeof page === 'string') return;
|
||||
if (page < 1 || page > totalPages.value) return;
|
||||
if (page !== props.modelValue) {
|
||||
emit('update:modelValue', page);
|
||||
emit('change', page);
|
||||
}
|
||||
}
|
||||
|
||||
function onPageSizeChange(e: Event) {
|
||||
const size = Number((e.target as HTMLSelectElement).value);
|
||||
emit('update:pageSize', size);
|
||||
emit('update:modelValue', 1);
|
||||
emit('change', 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="total > 0" class="pagination">
|
||||
<span class="page-total">{{ t('pagination.total', { total }) }}</span>
|
||||
<div class="page-nav">
|
||||
<button
|
||||
type="button"
|
||||
class="page-btn nav-btn"
|
||||
:disabled="modelValue === 1"
|
||||
@click="goTo(modelValue - 1)"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
<button
|
||||
v-for="(p, idx) in pages"
|
||||
:key="idx"
|
||||
type="button"
|
||||
class="page-btn"
|
||||
:class="{ active: p === modelValue, ellipsis: typeof p === 'string' }"
|
||||
:disabled="typeof p === 'string'"
|
||||
@click="goTo(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="page-btn nav-btn"
|
||||
:disabled="modelValue === totalPages"
|
||||
@click="goTo(modelValue + 1)"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div class="page-size-wrap">
|
||||
<select :value="pageSize" class="page-size-select" @change="onPageSizeChange">
|
||||
<option :value="10">{{ t('pagination.per_page', { size: 10 }) }}</option>
|
||||
<option :value="20">{{ t('pagination.per_page', { size: 20 }) }}</option>
|
||||
<option :value="50">{{ t('pagination.per_page', { size: 50 }) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-total {
|
||||
margin-right: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: #141414;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
border-color: var(--border-gold-soft);
|
||||
color: var(--text);
|
||||
background: rgba(212, 175, 55, 0.05);
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #111;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-btn.ellipsis {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
height: 32px;
|
||||
background: #141414;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.page-size-select:hover {
|
||||
border-color: var(--border-gold-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
108
apps/player/src/components/desktop/SportsCategoryBar.vue
Normal file
108
apps/player/src/components/desktop/SportsCategoryBar.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
type SportCategory = 'football' | 'basketball' | 'tennis';
|
||||
|
||||
const SPORT_CATEGORIES = [
|
||||
{ id: 'football' as const, icon: '⚽', labelKey: 'bet.sport_football', enabled: true },
|
||||
{ id: 'basketball' as const, icon: '🏀', labelKey: 'bet.sport_basketball', enabled: false },
|
||||
{ id: 'tennis' as const, icon: '🎾', labelKey: 'bet.sport_tennis', enabled: false },
|
||||
] as const;
|
||||
|
||||
const { t } = useI18n();
|
||||
const activeSport = ref<SportCategory>('football');
|
||||
|
||||
function selectSport(id: SportCategory, enabled: boolean) {
|
||||
if (!enabled) return;
|
||||
activeSport.value = id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sports-category-bar">
|
||||
<button
|
||||
v-for="sport in SPORT_CATEGORIES"
|
||||
:key="sport.id"
|
||||
type="button"
|
||||
class="sport-category"
|
||||
:class="{
|
||||
active: sport.enabled && activeSport === sport.id,
|
||||
disabled: !sport.enabled,
|
||||
}"
|
||||
:disabled="!sport.enabled"
|
||||
:aria-current="sport.enabled && activeSport === sport.id ? 'page' : undefined"
|
||||
@click="selectSport(sport.id, sport.enabled)"
|
||||
>
|
||||
<span class="sport-category-icon" aria-hidden="true">{{ sport.icon }}</span>
|
||||
<span class="sport-category-label">{{ t(sport.labelKey) }}</span>
|
||||
<span v-if="!sport.enabled" class="sport-category-soon">{{ t('bet.sport_coming_soon') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sports-category-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sports-category-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sport-category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
transition: background 0.18s, border-color 0.18s, color 0.18s;
|
||||
}
|
||||
|
||||
.sport-category:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sport-category.active {
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border-color: var(--border-gold-soft);
|
||||
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.sport-category.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.sport-category-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sport-category-label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sport-category-soon {
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -15,10 +15,12 @@ const props = defineProps<{
|
||||
}>;
|
||||
isSelected: (id: string) => boolean;
|
||||
locked?: boolean;
|
||||
/** PC 全宽紧凑:更小单元格、三列均分 */
|
||||
dense?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [id: string];
|
||||
pick: [id: string, event?: MouseEvent];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -34,9 +36,9 @@ const columns = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
function onPick(sel: CsSelection) {
|
||||
function onPick(sel: CsSelection, event?: MouseEvent) {
|
||||
if (props.locked) return;
|
||||
emit('pick', sel.id);
|
||||
emit('pick', sel.id, event);
|
||||
}
|
||||
|
||||
function formatOdds(odds: string) {
|
||||
@@ -46,7 +48,7 @@ function formatOdds(odds: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cs-panel" :class="{ 'cs-panel--locked': locked }">
|
||||
<div class="cs-panel" :class="{ 'cs-panel--locked': locked, 'cs-panel--dense': dense }">
|
||||
<div class="cols-head">
|
||||
<span>{{ t('bet.col_home') }}</span>
|
||||
<span>{{ t('bet.col_draw') }}</span>
|
||||
@@ -61,8 +63,9 @@ function formatOdds(odds: string) {
|
||||
type="button"
|
||||
class="score-card"
|
||||
:class="{ selected: isSelected(sel.id), 'score-card--locked': locked }"
|
||||
:data-bet-selection-id="sel.id"
|
||||
:disabled="locked"
|
||||
@click="onPick(sel)"
|
||||
@click="onPick(sel, $event)"
|
||||
>
|
||||
<span class="score-line">{{ sel.scoreDisplay }}</span>
|
||||
<span class="odds">{{ formatOdds(sel.odds) }}</span>
|
||||
@@ -98,14 +101,16 @@ function formatOdds(odds: string) {
|
||||
|
||||
.cols-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.score-card {
|
||||
@@ -124,7 +129,16 @@ function formatOdds(odds: string) {
|
||||
|
||||
.score-card.selected {
|
||||
border-color: var(--primary);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
background: rgba(212, 175, 55, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.35), 0 0 0 1px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.score-card.selected .score-line {
|
||||
color: #e8d48a;
|
||||
}
|
||||
|
||||
.score-card.selected .odds {
|
||||
color: #f0d875;
|
||||
}
|
||||
|
||||
.score-card--locked {
|
||||
@@ -148,4 +162,35 @@ function formatOdds(odds: string) {
|
||||
font-weight: 700;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.cs-panel--dense {
|
||||
padding: 4px 6px 6px;
|
||||
}
|
||||
|
||||
.cs-panel--dense .cols-head {
|
||||
font-size: 9px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.cs-panel--dense .cols-grid {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cs-panel--dense .col {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cs-panel--dense .score-card {
|
||||
min-height: 34px;
|
||||
padding: 4px 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cs-panel--dense .score-line {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.cs-panel--dense .odds {
|
||||
font-size: 9px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { resolveSelectionLabel } from '../../utils/selectionLabel';
|
||||
|
||||
@@ -12,11 +13,15 @@ const props = defineProps<{
|
||||
}[];
|
||||
isSelected: (id: string) => boolean;
|
||||
compact?: boolean;
|
||||
/** PC 行内横排:左标签右赔率,适合详情页全宽行 */
|
||||
horizontal?: boolean;
|
||||
/** PC 卡片内:上标签下赔率,列数随选项数量 */
|
||||
desktopCard?: boolean;
|
||||
locked?: boolean;
|
||||
lineValue?: string | number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ pick: [id: string] }>();
|
||||
const emit = defineEmits<{ pick: [id: string, event?: MouseEvent] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
function label(sel: (typeof props.selections)[number]) {
|
||||
@@ -29,23 +34,40 @@ function label(sel: (typeof props.selections)[number]) {
|
||||
return sel.selectionName;
|
||||
}
|
||||
|
||||
function onPick(id: string) {
|
||||
function onPick(id: string, event?: MouseEvent) {
|
||||
if (props.locked) return;
|
||||
emit('pick', id);
|
||||
emit('pick', id, event);
|
||||
}
|
||||
|
||||
const gridCols = computed(() => {
|
||||
const n = props.selections.length;
|
||||
if (n <= 1) return 1;
|
||||
if (n === 2) return 2;
|
||||
if (n === 3) return 3;
|
||||
return 4;
|
||||
});
|
||||
|
||||
const panelStyle = computed(() =>
|
||||
props.desktopCard ? { '--market-cols': String(gridCols.value) } : undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap" :class="{ compact, locked }">
|
||||
<div class="panel">
|
||||
<div class="wrap" :class="{ compact, horizontal, desktopCard, locked }">
|
||||
<div
|
||||
class="panel"
|
||||
:class="{ horizontal, 'desktop-card': desktopCard }"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<button
|
||||
v-for="sel in selections"
|
||||
:key="sel.id"
|
||||
type="button"
|
||||
class="odds-btn"
|
||||
:class="{ selected: isSelected(sel.id), 'odds-btn--locked': locked }"
|
||||
:data-bet-selection-id="sel.id"
|
||||
:disabled="locked"
|
||||
@click="onPick(sel.id)"
|
||||
@click="onPick(sel.id, $event)"
|
||||
>
|
||||
<span class="label">{{ label(sel) }}</span>
|
||||
<span class="odds">{{ sel.odds }}</span>
|
||||
@@ -60,6 +82,90 @@ function onPick(id: string) {
|
||||
background: #0c0c0c;
|
||||
}
|
||||
|
||||
.wrap.compact {
|
||||
padding: 2px 4px 4px;
|
||||
}
|
||||
|
||||
.wrap.compact:not(.horizontal) {
|
||||
padding: 2px 4px 4px;
|
||||
}
|
||||
|
||||
.wrap.horizontal {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.wrap.compact .panel:not(.horizontal) {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wrap.compact:not(.horizontal) .odds-btn {
|
||||
min-height: 30px;
|
||||
padding: 3px 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.panel.horizontal {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel.horizontal .odds-btn {
|
||||
flex: 1 1 72px;
|
||||
min-width: 72px;
|
||||
max-width: none;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.panel.horizontal .odds-btn .label {
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.panel.horizontal .odds-btn .odds {
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wrap.desktopCard {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.panel.desktop-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--market-cols, 3), minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel.desktop-card .odds-btn {
|
||||
min-height: 38px;
|
||||
padding: 4px 6px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.panel.desktop-card .odds-btn .label {
|
||||
font-size: 9px;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel.desktop-card .odds-btn .odds {
|
||||
font-size: 13px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.wrap.locked {
|
||||
opacity: 0.78;
|
||||
}
|
||||
@@ -86,7 +192,16 @@ function onPick(id: string) {
|
||||
|
||||
.odds-btn.selected {
|
||||
border-color: var(--primary);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
background: rgba(212, 175, 55, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.35), 0 0 0 1px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.odds-btn.selected .label {
|
||||
color: #e8d48a;
|
||||
}
|
||||
|
||||
.odds-btn.selected .odds {
|
||||
color: #f0d875;
|
||||
}
|
||||
|
||||
.odds-btn--locked {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { usePlayerProfile } from '../../composables/usePlayerProfile';
|
||||
import { formatMoney, parseAmount } from '../../utils/localeDisplay';
|
||||
import { teamFlagUrl } from '../../utils/teamFlag';
|
||||
import BetSuccessOverlay from '../BetSuccessOverlay.vue';
|
||||
import ConfirmDialog from '../ConfirmDialog.vue';
|
||||
import { buildBetPlaceConfirmMessage } from '../../utils/betPlaceConfirmMessage';
|
||||
|
||||
export interface OutrightPick {
|
||||
selectionId: string;
|
||||
@@ -51,6 +53,8 @@ const balance = ref(0);
|
||||
const successBalance = ref(0);
|
||||
const successStake = ref(0);
|
||||
const showSuccess = ref(false);
|
||||
const showPlaceConfirm = ref(false);
|
||||
const placeConfirmMessage = ref('');
|
||||
const currentOdds = ref('');
|
||||
const currentOddsVersion = ref('');
|
||||
const oddsDelta = ref<OddsDelta | null>(null);
|
||||
@@ -209,19 +213,54 @@ function setMaxStake() {
|
||||
if (balance.value > 0) setStake(balance.value);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!props.pick || stake.value <= 0) return;
|
||||
function validateSubmit(): boolean {
|
||||
if (!props.pick || stake.value <= 0) return false;
|
||||
if (stake.value > balance.value) {
|
||||
error.value = t('bet.outright_insufficient');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (hasSuspendedSelection.value) {
|
||||
error.value = t('bet.odds_suspended');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function onSubmitClick() {
|
||||
if (!props.pick) return;
|
||||
if (!validateSubmit()) return;
|
||||
if (hasPendingOddsChanges.value) {
|
||||
acceptPendingOdds();
|
||||
}
|
||||
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
|
||||
mode: 'single',
|
||||
items: [
|
||||
{
|
||||
selectionId: props.pick.selectionId,
|
||||
oddsVersion: currentOddsVersion.value,
|
||||
matchId: '',
|
||||
matchName: props.pick.eventTitle,
|
||||
marketId: '',
|
||||
marketName: props.pick.eventTitle,
|
||||
selectionName: `${props.pick.teamName} · ${props.pick.eventTitle}`,
|
||||
odds: oddsNum.value,
|
||||
marketType: 'OUTRIGHT',
|
||||
lineValue: null,
|
||||
allowSingle: true,
|
||||
allowParlay: false,
|
||||
},
|
||||
],
|
||||
totalStake: stake.value,
|
||||
totalReturn: estReturn.value,
|
||||
formatMoney: (amount) => formatMoney(amount, locale.value),
|
||||
getStake: () => stake.value,
|
||||
getOdds: () => oddsNum.value,
|
||||
});
|
||||
showPlaceConfirm.value = true;
|
||||
}
|
||||
|
||||
async function executeSubmit() {
|
||||
if (!props.pick || stake.value <= 0) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -251,6 +290,11 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSubmit() {
|
||||
showPlaceConfirm.value = false;
|
||||
await executeSubmit();
|
||||
}
|
||||
|
||||
function formatOdds(odds: string) {
|
||||
const n = parseFloat(odds);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : odds;
|
||||
@@ -326,7 +370,7 @@ function formatOdds(odds: string) {
|
||||
type="button"
|
||||
class="btn-confirm btn-gold-outline"
|
||||
:disabled="loading || stake <= 0 || hasSuspendedSelection"
|
||||
@click="submit"
|
||||
@click="onSubmitClick"
|
||||
>
|
||||
{{ submitButtonLabel }}
|
||||
</button>
|
||||
@@ -364,6 +408,16 @@ function formatOdds(odds: string) {
|
||||
</div>
|
||||
|
||||
<BetSuccessOverlay :show="showSuccess" @done="showSuccess = false" />
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="showPlaceConfirm"
|
||||
:title="t('bet.place_confirm_title')"
|
||||
:message="placeConfirmMessage"
|
||||
:confirm-text="t('bet.place_bet')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:loading="loading"
|
||||
@confirm="confirmSubmit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -9,6 +9,7 @@ const props = defineProps<{
|
||||
logoUrl?: string | null;
|
||||
disabled?: boolean;
|
||||
isWinner?: boolean;
|
||||
selectionId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ pick: [] }>();
|
||||
@@ -63,6 +64,7 @@ onUnmounted(() => {
|
||||
type="button"
|
||||
class="option-card"
|
||||
:class="{ 'option-card--disabled': disabled, 'option-card--winner': isWinner }"
|
||||
:data-bet-selection-id="selectionId || undefined"
|
||||
:disabled="disabled"
|
||||
@click="emit('pick')"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../../api';
|
||||
import OutrightEventSection, {
|
||||
type OutrightEvent,
|
||||
type OutrightSelection,
|
||||
@@ -10,7 +9,7 @@ import OutrightBetModal, { type OutrightPick } from './OutrightBetModal.vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import emptyMatchesImg from '../../assets/images/empty-matches.svg';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
||||
import { useOutrightEvents } from '../../composables/useOutrightEvents';
|
||||
|
||||
const props = defineProps<{
|
||||
activated?: boolean;
|
||||
@@ -18,14 +17,12 @@ const props = defineProps<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { loading, loadError, events, load } = useOutrightEvents();
|
||||
|
||||
function goLogin() {
|
||||
auth.showLoginPrompt('/bet');
|
||||
}
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref('');
|
||||
const events = ref<OutrightEvent[]>([]);
|
||||
const expanded = ref<Set<string>>(new Set());
|
||||
const modalOpen = ref(false);
|
||||
const activePick = ref<OutrightPick | null>(null);
|
||||
@@ -37,7 +34,6 @@ const totalSelections = computed(() =>
|
||||
|
||||
function syncExpandedAfterLoad() {
|
||||
const ids = events.value.map((e) => e.id);
|
||||
// 只保留仍然存在的 id,且最多保留 1 个
|
||||
const kept = [...expanded.value].filter((id) => ids.includes(id));
|
||||
if (kept.length > 0) {
|
||||
expanded.value = new Set([kept[0]]);
|
||||
@@ -50,59 +46,18 @@ function syncExpandedAfterLoad() {
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const hadData = events.value.length > 0;
|
||||
if (!hadData) loading.value = true;
|
||||
loadError.value = '';
|
||||
try {
|
||||
const { data } = await api.get('/player/outrights');
|
||||
const list = (data?.data ?? []) as OutrightEvent[];
|
||||
const fresh = list.filter((e) => e.selections?.length > 0);
|
||||
if (!hadData) {
|
||||
events.value = fresh;
|
||||
syncExpandedAfterLoad();
|
||||
} else {
|
||||
mergeOddsOnly(fresh);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (!hadData) 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 {
|
||||
if (!hadData) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOddsOnly(fresh: OutrightEvent[]) {
|
||||
const freshMap = new Map<string, OutrightEvent>();
|
||||
for (const e of fresh) freshMap.set(e.id, e);
|
||||
|
||||
for (const event of events.value) {
|
||||
const freshEvent = freshMap.get(event.id);
|
||||
if (!freshEvent) continue;
|
||||
const selMap = new Map<string, OutrightSelection>();
|
||||
for (const s of freshEvent.selections) selMap.set(s.id, s);
|
||||
for (const sel of event.selections) {
|
||||
const fs = selMap.get(sel.id);
|
||||
if (fs) {
|
||||
sel.odds = fs.odds;
|
||||
sel.oddsVersion = fs.oddsVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useOnLocaleChange(load);
|
||||
watch(
|
||||
() => events.value.length,
|
||||
(len, prev) => {
|
||||
if (len > 0 && prev === 0) syncExpandedAfterLoad();
|
||||
},
|
||||
);
|
||||
|
||||
// 每次切回优胜冠军 Tab 时静默刷新赔率
|
||||
watch(
|
||||
() => props.activated,
|
||||
(active) => {
|
||||
if (active && events.value.length > 0) void load();
|
||||
if (active && events.value.length > 0) void load({ silent: true });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user