feat(player+api): 桌面端钱包/注单栏重构,首页快速下注与移动端体验优化

桌面端:
- 新增 DesktopWalletPageHeader 统一钱包子导航,移除重复大标题
- 重构我的余额页布局(居中窄卡片 + 余额/统计/账户设置)
- 新增 DesktopBetSlipRail 可折叠注单侧栏,优化 BetSlipPanel 交互
- 首页 upcoming 赛事卡片支持胜平负/让球/大小球快速下注
- 优化 DesktopShell 布局、3D 轮播、各账户/消息/充值页面样式

移动端:
- 重构 MobileWalletView 钱包页 UI
- 修复 MarketSelectionsPanel 下注区黑色背景问题
- 新增 PageBackButton 与 useSmartBack 智能返回逻辑
- 优化公告/消息/个人中心等详情页导航

API:
- listUpcomingPublished 支持 includeMarkets,返回首页卡片所需核心玩法
This commit is contained in:
2026-06-30 16:11:27 +08:00
parent ef5b9416cc
commit 7f563326d8
73 changed files with 2866 additions and 1552 deletions

View File

@@ -190,7 +190,7 @@ export class PlayerController {
this.content.listActive('BANNER', locale),
this.content.listActiveAnnouncements(locale),
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
this.matches.listUpcomingPublished(locale),
this.matches.listUpcomingPublished(locale, { includeMarkets: true }),
this.systemConfig.getInboxFeatureEnabled(),
]);
const timeZone = safeTimeZone(headerTimeZone);

View File

@@ -1592,17 +1592,42 @@ export class MatchesService {
);
}
/** 首页卡片快速下注所需的核心玩法类型 */
private static readonly HOME_CARD_MARKET_TYPES = [
'FT_1X2',
'FT_HANDICAP',
'FT_OVER_UNDER',
] as const;
/** 未来 N 天内开赛的已发布赛事(按开赛时间升序,不限 isHot */
async listUpcomingPublished(
locale = 'en-US',
options?: { limit?: number; days?: number },
options?: { limit?: number; days?: number; includeMarkets?: boolean },
) {
const limit = options?.limit ?? 50;
const days = options?.days ?? 3;
const includeMarkets = options?.includeMarkets ?? false;
const now = new Date();
const end = new Date(now);
end.setDate(end.getDate() + days);
const marketsRelation = includeMarkets
? {
where: {
status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
showOnPlayer: true,
marketType: { in: [...MatchesService.HOME_CARD_MARKET_TYPES] },
},
include: {
selections: {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
orderBy: { sortOrder: 'asc' as const },
},
},
orderBy: { sortOrder: 'asc' as const },
}
: this.playerMarketStatusInclude;
const matches = await this.prisma.match.findMany({
where: {
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
@@ -1625,14 +1650,14 @@ export class MatchesService {
homeTeam: true,
awayTeam: true,
score: true,
markets: this.playerMarketStatusInclude,
markets: marketsRelation,
},
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
take: limit,
});
return Promise.all(
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: true })),
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: !includeMarkets })),
);
}

View File

@@ -12,6 +12,12 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@200;300;400&display=swap"
rel="stylesheet"
/>
<title>TheBet365</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #F5F7FA; }

View File

@@ -685,13 +685,13 @@ watch(
.drawer {
width: 100%;
max-height: 88vh;
background: #101010;
background: var(--bg-card);
border-radius: 16px 16px 0 0;
border-top: 1px solid var(--border-gold-soft);
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 -20px 44px rgba(0, 0, 0, 0.48);
box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.12);
}
.drawer-head {
@@ -703,8 +703,8 @@ watch(
align-items: flex-start;
gap: 12px;
padding: 14px 14px 10px;
background: linear-gradient(180deg, #1d1d1d 0%, #111 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: linear-gradient(180deg, #FFFFFF 0%, var(--bg-body) 100%);
border-bottom: 1px solid var(--border);
}
.drawer-kicker {
@@ -728,7 +728,7 @@ watch(
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
background: var(--bg-hover);
color: var(--text-muted);
font-size: 18px;
padding: 0;
@@ -742,8 +742,8 @@ watch(
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(212, 175, 55, 0.08);
border-bottom: 1px solid rgba(212, 175, 55, 0.18);
background: rgba(0, 102, 204, 0.06);
border-bottom: 1px solid rgba(0, 102, 204, 0.12);
color: var(--text-muted);
font-size: 12px;
}
@@ -759,7 +759,7 @@ watch(
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
background: var(--bg-hover);
color: var(--text-muted);
padding: 0;
}
@@ -769,23 +769,23 @@ watch(
grid-template-columns: repeat(2, 1fr);
padding: 8px 12px 0;
gap: 6px;
background: #101010;
background: var(--bg-body);
}
.slip-tab {
min-height: 38px;
border-radius: 6px 6px 0 0;
border: 1px solid var(--border);
background: #171717;
background: var(--bg-card);
color: var(--text-muted);
font-size: 13px;
font-weight: 800;
}
.slip-tab.active {
border-color: var(--border-gold-soft);
background: rgba(212, 175, 55, 0.13);
color: var(--primary-light);
border-color: var(--border-active);
background: rgba(0, 102, 204, 0.08);
color: var(--primary);
}
.tab-count {
@@ -797,7 +797,7 @@ watch(
margin-left: 6px;
border-radius: 9px;
background: var(--primary);
color: #111;
color: #fff;
font-size: 11px;
font-weight: 900;
}
@@ -822,15 +822,15 @@ watch(
gap: 10px;
align-items: center;
padding: 11px 12px;
background: #151515;
border: 1px solid #292929;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 7px;
margin-bottom: 8px;
}
.slip-item--single {
border-color: var(--border-gold-soft);
background: linear-gradient(180deg, rgba(212, 175, 55, 0.12), rgba(18, 18, 18, 0.94));
border-color: var(--border-active);
background: linear-gradient(180deg, rgba(0, 102, 204, 0.06), var(--bg-card));
}
.item-main {
@@ -891,14 +891,12 @@ watch(
min-height: 48px;
padding: 0 14px;
border-radius: 8px;
background: linear-gradient(180deg, #4b370d 0%, #241905 100%);
border: 1px solid var(--primary-light);
color: #ffe892;
background: linear-gradient(180deg, var(--primary-light) 0%, var(--primary) 100%);
border: 1px solid var(--border-active);
color: #fff;
font-size: 14px;
font-weight: 900;
box-shadow:
0 0 0 1px rgba(255, 232, 146, 0.18) inset,
0 6px 18px rgba(212, 175, 55, 0.18);
box-shadow: 0 4px 12px rgba(0, 61, 107, 0.15);
}
.btn-add-parlay span {
@@ -908,8 +906,8 @@ watch(
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary-light);
color: #191100;
background: #fff;
color: var(--primary);
font-size: 14px;
line-height: 1;
}
@@ -963,8 +961,8 @@ watch(
.stake-area {
margin: 12px 0 12px;
padding: 12px;
background: #151515;
border: 1px solid #292929;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 8px;
}
@@ -990,17 +988,17 @@ watch(
grid-template-columns: auto 1fr auto;
align-items: center;
margin-top: 10px;
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
border-radius: 6px;
overflow: hidden;
background: #0c0c0c;
background: var(--bg-card);
}
.currency {
padding: 0 10px;
color: var(--text-muted);
font-size: 13px;
border-right: 1px solid #2d2d2d;
border-right: 1px solid var(--border);
}
.stake-input-row input {
@@ -1065,7 +1063,7 @@ watch(
.quick-stakes button {
min-height: 50px;
background: #7b787d;
background: var(--bg-body);
color: var(--text);
font-size: 20px;
font-weight: 800;
@@ -1087,9 +1085,9 @@ watch(
grid-template-columns: 1fr;
gap: 10px;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(14, 14, 14, 0.98);
border-top: 1px solid var(--border-gold-soft);
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
background: var(--bg-card);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
}
.drawer-foot--split {
@@ -1101,7 +1099,7 @@ watch(
min-height: 48px;
border-radius: 7px;
background: linear-gradient(180deg, var(--primary-light), var(--primary));
color: #111;
color: #fff;
font-size: 16px;
font-weight: 900;
}

View File

@@ -103,18 +103,18 @@ function onConfirm() {
.confirm-modal {
width: 100%;
max-width: 340px;
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 22px 18px 16px;
box-shadow: 0 0 24px rgba(212, 175, 55, 0.08);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
}
.confirm-title {
margin: 0 0 10px;
font-size: 17px;
font-weight: 800;
color: var(--gold, #c8a84e);
color: var(--text);
text-align: center;
line-height: 1.35;
}
@@ -123,7 +123,7 @@ function onConfirm() {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.6;
color: #c8c8c8;
color: var(--text-muted);
text-align: center;
white-space: pre-line;
}
@@ -149,15 +149,15 @@ function onConfirm() {
}
.confirm-btn.cancel {
border: 1px solid #333;
border: 1px solid var(--border);
background: transparent;
color: #888;
color: var(--text-muted);
}
.confirm-btn.confirm {
border: none;
background: linear-gradient(135deg, #d4a017, #e8c84a);
color: #1a1a1a;
background: var(--primary);
color: #fff;
}
.confirm-btn.confirm.danger {
@@ -184,4 +184,25 @@ function onConfirm() {
.confirm-fade-leave-to .confirm-modal {
transform: scale(0.96);
}
/* === Desktop Theme Overrides === */
:global(.is-desktop) .confirm-modal {
background: var(--bg-card);
border: 1px solid var(--border);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.1);
}
:global(.is-desktop) .confirm-title {
color: var(--text);
}
:global(.is-desktop) .confirm-message {
color: var(--text-muted);
}
:global(.is-desktop) .confirm-btn.cancel {
border-color: var(--border);
color: var(--text-muted);
}
:global(.is-desktop) .confirm-btn.confirm {
background: var(--primary);
color: #fff;
}
</style>

View File

@@ -32,12 +32,27 @@ const showMessageActions = computed(
() => inboxEnabled.value && activeTab.value === 'messages' && auth.token && hasMessages.value && !selectedMessageId.value,
);
const isBettingDesktop = computed(() => {
if (!isDesktop.value) return false;
const hideOnMessagesPage = computed(
() => isDesktop.value && route.path.startsWith('/messages'),
);
const offsetForBetSlip = computed(() => {
if (!isDesktop.value || hideOnMessagesPage.value) return false;
const p = route.path;
return p === '/bet' || p.startsWith('/match/') || p.startsWith('/outright/');
if (p === '/bets' || p.startsWith('/bets/')) return false;
if (p.startsWith('/wallet') || p.startsWith('/profile')) return false;
return true;
});
watch(
() => route.path,
(path) => {
if (isDesktop.value && path.startsWith('/messages') && isOpen.value) {
close();
}
},
);
watch(isOpen, (opened) => {
if (opened) {
if (!inboxEnabled.value) activeTab.value = 'support';
@@ -86,7 +101,11 @@ onMounted(() => {
</script>
<template>
<div class="floating-mailbox" :class="{ 'is-betting-desktop': isBettingDesktop }">
<div
v-if="!hideOnMessagesPage"
class="floating-mailbox"
:class="{ 'is-betting-desktop': offsetForBetSlip }"
>
<!-- Floating Button -->
<button
type="button"
@@ -152,7 +171,12 @@ onMounted(() => {
<div class="popup-body">
<template v-if="activeTab === 'messages' && inboxEnabled">
<MobileMessageDetailView v-if="selectedMessageId" :id="selectedMessageId" embedded @back="selectedMessageId = null" />
<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" />
@@ -224,10 +248,10 @@ onMounted(() => {
width: 380px;
height: 600px;
max-height: calc(100vh - 120px);
background: var(--bg-card, #141414);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -240,7 +264,7 @@ onMounted(() => {
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
background: var(--bg-body);
height: 48px;
}
@@ -264,7 +288,7 @@ onMounted(() => {
}
.tab-btn:hover {
color: #fff;
color: var(--primary);
}
.tab-btn.active {
@@ -273,8 +297,8 @@ onMounted(() => {
}
.tab-badge {
background: rgba(255, 255, 255, 0.1);
color: #fff;
background: var(--bg-hover);
color: var(--text-muted);
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
@@ -282,7 +306,7 @@ onMounted(() => {
.tab-btn.active .tab-badge {
background: var(--primary);
color: #141414;
color: #fff;
}
.header-actions {
@@ -305,8 +329,8 @@ onMounted(() => {
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
background: var(--bg-hover);
color: var(--primary);
}
.action-btn.danger:hover {
@@ -315,13 +339,13 @@ onMounted(() => {
}
.action-btn.close:hover {
background: rgba(255, 255, 255, 0.1);
background: var(--bg-hover);
}
.popup-body {
height: calc(100% - 48px);
position: relative;
background: #0f0f0f;
background: var(--bg-body);
overflow-y: auto;
}

View File

@@ -130,7 +130,7 @@ watch(totalPages, () => {
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: rgba(201, 162, 39, 0.05);
background: rgba(0, 102, 204, 0.04);
}
.header-title {
@@ -139,7 +139,7 @@ watch(totalPages, () => {
gap: 8px;
font-size: 14px;
font-weight: 800;
color: var(--primary-light);
color: var(--primary);
}
.announce-icon {
@@ -158,8 +158,8 @@ watch(totalPages, () => {
height: 24px;
border-radius: 4px;
border: 1px solid var(--border);
background: #1a1a1a;
color: var(--gold);
background: var(--bg-card);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
@@ -189,7 +189,7 @@ watch(totalPages, () => {
border: none;
background: transparent;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
border-bottom: 1px solid var(--border);
transition: background 0.2s;
}
@@ -198,22 +198,23 @@ watch(totalPages, () => {
}
.announce-row:hover {
background: rgba(255, 255, 255, 0.03);
background: var(--bg-hover);
}
.row-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.title {
flex: 1;
width: 100%;
font-size: 13px;
color: #fff;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -226,7 +227,7 @@ watch(totalPages, () => {
}
.chevron {
color: var(--gold);
color: var(--text-muted);
font-size: 18px;
font-family: monospace;
}

View File

@@ -195,11 +195,11 @@ onActivated(tryLoad);
}
.login-link {
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
border-radius: 8px;
padding: 8px 14px;
background: rgba(212, 175, 55, 0.1);
color: var(--gold);
background: rgba(0, 102, 204, 0.08);
color: var(--primary);
font-size: 13px;
font-weight: 600;
}
@@ -230,7 +230,7 @@ onActivated(tryLoad);
}
.list-row.unread .title {
color: #fff;
color: var(--text);
font-weight: 700;
}
@@ -243,8 +243,8 @@ onActivated(tryLoad);
}
.list-row.unread .row-dot {
background: var(--gold);
box-shadow: 0 0 8px rgba(212, 175, 55, 0.45);
background: var(--primary);
box-shadow: 0 0 8px rgba(0, 102, 204, 0.35);
}
.row-main {
@@ -266,7 +266,7 @@ onActivated(tryLoad);
flex: 1;
min-width: 0;
font-size: 15px;
color: #d8d8d8;
color: var(--text);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
@@ -279,13 +279,13 @@ onActivated(tryLoad);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: #888;
background: rgba(255, 255, 255, 0.06);
color: var(--text-muted);
background: var(--bg-hover);
}
.status-badge.unread {
color: var(--gold);
background: rgba(212, 175, 55, 0.12);
color: var(--primary);
background: rgba(0, 102, 204, 0.1);
}
.preview {
@@ -341,8 +341,8 @@ onActivated(tryLoad);
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #141414;
color: var(--gold);
background: var(--bg-hover);
color: var(--primary);
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useSmartBack } from '../composables/useSmartBack';
const props = withDefaults(
defineProps<{
fallback?: string;
variant?: 'desktop' | 'mobile';
showLabel?: boolean;
}>(),
{
variant: 'desktop',
showLabel: true,
},
);
const { t } = useI18n();
const { goBack } = useSmartBack(() => props.fallback);
</script>
<template>
<button
type="button"
class="page-back-btn"
:class="[`page-back-btn--${variant}`]"
:aria-label="t('common.back')"
@click="goBack"
>
<span class="page-back-icon" aria-hidden="true"></span>
<span v-if="showLabel && variant === 'desktop'" class="page-back-label">
{{ t('common.back') }}
</span>
</button>
</template>
<style scoped>
.page-back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: none;
background: none;
cursor: pointer;
flex-shrink: 0;
transition: color 0.2s;
}
.page-back-btn--desktop {
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
}
.page-back-btn--desktop:hover {
color: var(--text);
}
.page-back-btn--mobile {
color: #003d6b;
font-size: 24px;
line-height: 1;
padding: 0 8px;
}
.page-back-icon {
line-height: 1;
}
.page-back-btn--desktop .page-back-icon {
font-size: 18px;
}
</style>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -10,14 +10,14 @@ const isWalletSection = computed(
() => route.path.startsWith('/wallet') || route.path === '/profile/cashbacks',
);
const hubTitleKey = computed(() => (isWalletSection.value ? 'nav.wallet' : 'nav.bet_history'));
const hubTitleKey = computed(() => (isWalletSection.value ? 'nav.my_wallet' : 'nav.bet_history'));
const menuItems = computed(() => {
if (isWalletSection.value) {
return [
{
path: '/wallet',
labelKey: 'nav.wallet',
labelKey: 'nav.my_wallet',
match: (p: string) =>
p === '/wallet' ||
p === '/wallet/detail' ||
@@ -94,7 +94,7 @@ const menuItems = computed(() => {
}
.nav-item:hover {
color: #fff;
color: var(--primary);
}
.nav-item.active {
@@ -111,7 +111,7 @@ const menuItems = computed(() => {
width: 6px;
height: 6px;
border-radius: 50%;
background: #333;
background: var(--bg-hover);
transition: all 0.2s;
}

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter, RouterLink } from 'vue-router';
@@ -75,6 +75,8 @@ const currentMatchId = computed(() => {
return outright ? outright[1] : '';
});
const isHomePage = computed(() => route.path === '/');
const showMatchHistoryFilter = computed(() => Boolean(currentMatchId.value));
const singleSlipItems = computed<SlipItem[]>(() => {
@@ -911,7 +913,7 @@ onUnmounted(() => {
<p v-if="activeTab !== 'history' && oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
<!-- Body -->
<div class="panel-body">
<div class="panel-body" :class="{ 'no-scrollbar': isHomePage }">
<template v-if="activeTab === 'history'">
<div v-if="historyInitialLoading" class="history-loading">
<GoldSpinner :size="28" />
@@ -1154,7 +1156,7 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(16, 16, 16, 0.95);
background: var(--bg-card);
}
.panel-head {
@@ -1253,7 +1255,7 @@ onUnmounted(() => {
.badge {
background: var(--primary);
color: #111;
color: var(--bg-card);
font-size: 9px;
font-weight: 800;
padding: 1px 4px;
@@ -1305,7 +1307,7 @@ onUnmounted(() => {
}
.slip-card {
background: #1e1e1e;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 4px;
padding: 7px 8px;
@@ -1398,7 +1400,7 @@ onUnmounted(() => {
.selection-name {
font-size: 11px;
font-weight: 700;
color: #fff;
color: var(--text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -1429,7 +1431,7 @@ onUnmounted(() => {
}
.odds-suspended {
background: rgba(255, 255, 255, 0.05);
background: rgba(0, 0, 0, 0.04);
color: var(--text-muted);
text-decoration: line-through;
}
@@ -1437,7 +1439,7 @@ onUnmounted(() => {
.card-stake-block {
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
border-top: 1px solid rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
gap: 4px;
@@ -1453,7 +1455,7 @@ onUnmounted(() => {
flex: 1;
display: flex;
align-items: center;
background: #0d0d0d;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 6px;
@@ -1465,7 +1467,7 @@ onUnmounted(() => {
flex: 1;
background: transparent;
border: none !important;
color: #fff;
color: var(--text);
padding: 0;
font-size: 11px;
font-weight: 700;
@@ -1526,7 +1528,7 @@ onUnmounted(() => {
.card-single-btn:hover:not(:disabled) {
background: rgba(0, 102, 204, 0.22);
border-color: var(--border-active);
color: #fff;
color: var(--text);
}
.card-parlay-btn {
@@ -1538,13 +1540,13 @@ onUnmounted(() => {
.card-parlay-btn:hover:not(:disabled) {
background: rgba(0, 102, 204, 0.1);
border-color: var(--border-active);
color: #fff;
color: var(--text);
}
.card-parlay-btn.active {
border-style: solid;
border-color: rgba(0, 102, 204, 0.35);
background: rgba(255, 255, 255, 0.03);
background: rgba(0, 0, 0, 0.04);
color: var(--text-muted);
}
@@ -1604,7 +1606,7 @@ onUnmounted(() => {
font-weight: 700;
color: var(--text-muted);
padding: 4px 6px;
background: #141414;
background: var(--bg-body);
border-radius: 4px;
}
@@ -1613,7 +1615,7 @@ onUnmounted(() => {
}
.stake-control {
background: #141414;
background: var(--bg-body);
border-radius: 4px;
padding: 10px;
display: flex;
@@ -1624,7 +1626,7 @@ onUnmounted(() => {
.stake-input-box {
display: flex;
align-items: center;
background: #0d0d0d;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 10px;
@@ -1642,7 +1644,7 @@ onUnmounted(() => {
flex: 1;
background: transparent;
border: none !important;
color: #fff;
color: var(--text);
padding: 0;
font-size: 14px;
font-weight: 700;
@@ -1668,7 +1670,7 @@ onUnmounted(() => {
.chip-btn {
flex: 1;
background: #1e1e1e;
background: var(--bg-body);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
@@ -1680,7 +1682,7 @@ onUnmounted(() => {
.chip-btn:hover {
border-color: var(--border-active);
color: #fff;
color: var(--text);
}
.panel-error-msg {
@@ -1700,7 +1702,7 @@ onUnmounted(() => {
.panel-bottom-fixed {
flex-shrink: 0;
border-top: 1px solid var(--border);
background: rgba(16, 16, 16, 0.98);
background: var(--bg-card);
}
.panel-summary {
@@ -1763,7 +1765,7 @@ onUnmounted(() => {
padding: 10px;
border-radius: 4px;
background: linear-gradient(180deg, var(--primary-light) 0%, var(--primary) 100%);
color: #FFFFFF;
color: var(--text);
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
@@ -1844,7 +1846,7 @@ onUnmounted(() => {
padding: 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: #1e1e1e;
background: var(--bg-body);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, background 0.15s;
@@ -1866,7 +1868,7 @@ onUnmounted(() => {
.history-pick {
font-size: 11px;
font-weight: 700;
color: #fff;
color: var(--text);
line-height: 1.35;
min-width: 0;
}

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
defineProps<{
@@ -60,7 +60,7 @@ const { t } = useI18n();
.data-table-container {
width: 100%;
overflow-x: auto;
background: rgba(20, 20, 20, 0.95);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
}
@@ -73,7 +73,7 @@ const { t } = useI18n();
}
th {
background: #141414;
background: var(--bg-body);
color: var(--text-muted);
font-weight: 800;
text-transform: uppercase;

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -10,6 +10,8 @@ const props = defineProps<{
fallbackTo?: string;
}>();
const AUTOPLAY_MS = 5500;
const { t } = useI18n();
const router = useRouter();
const active = ref(0);
@@ -18,6 +20,7 @@ const FALLBACK_IMG = '/uploads/banners/welcome.svg';
const slideCount = computed(() => props.banners.length);
const canLoop = computed(() => slideCount.value > 1);
const activeBanner = computed(() => props.banners[active.value]);
function imageUrl(banner: BannerItem) {
return banner.translation?.imageUrl || defaultBannerImg || FALLBACK_IMG;
@@ -34,61 +37,6 @@ 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;
@@ -102,11 +50,7 @@ function prev() {
goTo(active.value - 1);
}
function onBannerClick(banner: BannerItem, index: number) {
if (normalizeOffset(index) !== 0) {
goTo(index);
return;
}
function onBannerClick(banner: BannerItem) {
if (banner.id) {
void router.push(`/announcements/${banner.id}`);
return;
@@ -119,7 +63,7 @@ function onBannerClick(banner: BannerItem, index: number) {
function startAutoPlay() {
stopAutoPlay();
if (!canLoop.value) return;
timer.value = setInterval(next, 5000);
timer.value = setInterval(next, AUTOPLAY_MS);
}
function stopAutoPlay() {
@@ -144,46 +88,54 @@ onUnmounted(stopAutoPlay);
<template>
<section
v-if="banners.length"
class="banner-coverflow"
class="hero-carousel"
@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">
<div class="hero-frame">
<div class="hero-stage">
<button
v-for="(banner, i) in banners"
:key="banner.id ?? i"
type="button"
class="hero-slide"
:class="{ 'is-active': i === active }"
:aria-label="title(banner)"
@click="onBannerClick(banner)"
>
<img
v-if="imageUrl(banner)"
:src="imageUrl(banner)"
:alt="''"
class="slide-img"
alt=""
class="hero-backdrop"
aria-hidden="true"
: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>
<img
v-if="imageUrl(banner)"
:src="imageUrl(banner)"
:alt="title(banner)"
class="hero-img"
:loading="i === 0 ? 'eager' : 'lazy'"
@error="onImgError"
/>
<div v-else class="hero-fallback">
<span>{{ title(banner) }}</span>
</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 class="hero-shade" aria-hidden="true" />
<div v-if="i === active" class="hero-caption">
<span class="hero-badge">{{ t('home.announcement_badge') || '公告' }}</span>
<h3 class="hero-title">{{ title(banner) }}</h3>
<span class="hero-link">
{{ t('announcements.go_link') }}
<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>
</span>
</div>
</div>
</button>
</button>
</div>
<button
v-if="canLoop"
@@ -203,109 +155,89 @@ onUnmounted(stopAutoPlay);
>
<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 v-if="canLoop" class="hero-footer">
<div class="hero-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>
<div class="hero-progress" aria-hidden="true">
<div :key="active" class="hero-progress-fill" />
</div>
</div>
</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 v-if="activeBanner && canLoop" class="hero-counter" aria-hidden="true">
{{ String(active + 1).padStart(2, '0') }} / {{ String(slideCount).padStart(2, '0') }}
</div>
</section>
</template>
<style scoped>
.banner-coverflow {
width: 100%;
padding: 4px 0 0;
}
.coverflow-stage {
.hero-carousel {
position: relative;
height: clamp(260px, 28vw, 360px);
perspective: 1000px;
width: 100%;
}
.coverflow-slide {
.hero-frame {
position: relative;
border-radius: 14px;
overflow: hidden;
border: 1px solid var(--border);
background: linear-gradient(135deg, #EEF4FA 0%, #FFFFFF 55%, #F5F7FA 100%);
box-shadow: var(--shadow);
}
.hero-stage {
position: relative;
height: clamp(220px, 24vw, 300px);
}
.hero-slide {
position: absolute;
top: 50%;
width: 42%;
max-width: 560px;
height: 90%;
inset: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
opacity: 0;
transform: scale(1.015);
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;
opacity 0.65s ease,
transform 0.65s cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: none;
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);
.hero-slide.is-active {
opacity: 1;
transform: scale(1);
pointer-events: auto;
z-index: 2;
}
.coverflow-slide.is-side:hover .slide-card {
filter: brightness(0.88) saturate(1);
.hero-backdrop {
position: absolute;
inset: -24px;
width: calc(100% + 48px);
height: calc(100% + 48px);
object-fit: cover;
filter: blur(22px) saturate(1.1);
opacity: 0.42;
transform: scale(1.06);
pointer-events: none;
}
.coverflow-slide.is-active .slide-card {
filter: none;
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.5),
0 0 28px rgba(0, 102, 204, 0.22),
0 0 56px rgba(0, 102, 204, 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(0, 102, 204, 0.16),
0 0 48px rgba(0, 102, 204, 0.08);
}
50% {
transform: scale(1.008);
box-shadow:
0 18px 44px rgba(0, 0, 0, 0.52),
0 0 32px rgba(0, 102, 204, 0.26),
0 0 60px rgba(0, 102, 204, 0.12);
}
}
.slide-img {
.hero-img {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: contain;
@@ -313,111 +245,123 @@ onUnmounted(stopAutoPlay);
display: block;
}
.slide-fallback {
.hero-fallback {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(0, 102, 204, 0.12), #1a1a1a);
background: linear-gradient(135deg, rgba(0, 61, 107, 0.08), rgba(0, 102, 204, 0.04));
font-size: clamp(18px, 2.2vw, 26px);
font-weight: 800;
color: var(--primary);
letter-spacing: 0.02em;
}
.fallback-icon {
width: 40px;
height: 40px;
color: rgba(0, 102, 204, 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 {
.hero-shade {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(0, 102, 204, 0.45);
animation: click-ring-pulse 2.8s ease-out infinite;
z-index: 2;
background:
linear-gradient(180deg, rgba(0, 45, 82, 0.04) 0%, transparent 38%),
linear-gradient(0deg, rgba(0, 36, 71, 0.72) 0%, rgba(0, 36, 71, 0.28) 42%, transparent 72%);
pointer-events: none;
}
.click-icon {
position: relative;
width: 15px;
height: 15px;
color: rgba(0, 102, 204, 0.88);
animation: click-icon-bob 2.8s ease-in-out infinite;
.hero-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
padding: 20px 24px 18px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
text-align: left;
animation: caption-in 0.55s ease both;
}
@keyframes click-ring-pulse {
0% {
transform: scale(0.9);
opacity: 0.55;
}
70% {
transform: scale(1.35);
@keyframes caption-in {
from {
opacity: 0;
transform: translateY(10px);
}
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);
to {
opacity: 1;
transform: translateY(0);
}
}
.coverflow-slide.is-active:hover .click-hint {
opacity: 0;
transition: opacity 0.2s ease;
.hero-badge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.28);
color: rgba(255, 255, 255, 0.92);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
backdrop-filter: blur(6px);
}
@media (prefers-reduced-motion: reduce) {
.coverflow-slide.is-active .slide-card,
.click-ring,
.click-icon {
animation: none;
}
.hero-title {
margin: 0;
max-width: min(72%, 560px);
font-size: clamp(16px, 1.8vw, 22px);
font-weight: 800;
line-height: 1.35;
color: #fff;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.25);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.hero-link {
display: inline-flex;
align-items: center;
gap: 2px;
margin-top: 2px;
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.88);
transition: color 0.2s ease, transform 0.2s ease;
}
.hero-link svg {
width: 16px;
height: 16px;
}
.hero-slide:hover .hero-link {
color: #fff;
transform: translateX(2px);
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgba(0, 102, 204, 0.4);
background: rgba(10, 10, 10, 0.75);
color: var(--primary-light);
border: 1px solid rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.88);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
transition: background 0.2s, border-color 0.2s;
z-index: 10;
box-shadow: 0 4px 14px rgba(0, 61, 107, 0.12);
transition: background 0.2s, border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
.nav-btn svg {
@@ -426,30 +370,42 @@ onUnmounted(stopAutoPlay);
}
.nav-btn:hover {
background: rgba(0, 102, 204, 0.2);
border-color: var(--primary);
background: #fff;
border-color: #fff;
transform: translateY(-50%) scale(1.05);
box-shadow: 0 6px 18px rgba(0, 61, 107, 0.16);
}
.nav-btn.prev {
left: 4px;
left: 14px;
}
.nav-btn.next {
right: 4px;
right: 14px;
}
.coverflow-dots {
.hero-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 8;
pointer-events: none;
}
.hero-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 14px;
gap: 6px;
padding: 0 0 10px;
pointer-events: auto;
}
.dot {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.45);
border: none;
padding: 0;
cursor: pointer;
@@ -457,12 +413,64 @@ onUnmounted(stopAutoPlay);
}
.dot.active {
width: 22px;
border-radius: 4px;
background: var(--primary);
width: 20px;
border-radius: 3px;
background: #fff;
}
.dot:hover:not(.active) {
background: rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.72);
}
.hero-progress {
height: 3px;
background: rgba(255, 255, 255, 0.18);
}
.hero-progress-fill {
height: 100%;
width: 0;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.55), #fff);
animation: hero-progress 5.5s linear forwards;
}
@keyframes hero-progress {
from {
width: 0%;
}
to {
width: 100%;
}
}
.hero-counter {
position: absolute;
top: 14px;
right: 14px;
z-index: 10;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(0, 61, 107, 0.1);
color: var(--primary);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
backdrop-filter: blur(8px);
box-shadow: 0 2px 8px rgba(0, 61, 107, 0.08);
}
@media (prefers-reduced-motion: reduce) {
.hero-slide,
.hero-caption,
.hero-progress-fill {
animation: none;
transition: none;
}
.hero-progress-fill {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
import BetSlipPanel from './BetSlipPanel.vue';
import { useBetSlipStore } from '../../stores/betSlip';
const { t } = useI18n();
const slip = useBetSlipStore();
watch(
() => slip.totalCount,
(next, prev) => {
if (next > prev) slip.expandPanel();
},
);
</script>
<template>
<aside
class="desktop-betslip-rail"
:class="{ 'is-collapsed': !slip.panelExpanded }"
:aria-expanded="slip.panelExpanded"
>
<button
type="button"
class="rail-toggle"
:class="{ 'is-collapsed': !slip.panelExpanded }"
:aria-label="slip.panelExpanded ? t('common.collapse') : t('common.expand')"
@click="slip.togglePanelExpanded()"
>
<svg class="rail-toggle-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M10 8l4 4-4 4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button
v-if="!slip.panelExpanded"
type="button"
class="rail-collapsed-hint"
:aria-label="t('bet.bet_slip')"
@click="slip.expandPanel()"
>
<span v-if="slip.totalCount" class="rail-badge">{{ slip.totalCount > 99 ? '99+' : slip.totalCount }}</span>
<span class="rail-label">{{ t('bet.bet_slip') }}</span>
</button>
<div class="rail-content" :hidden="!slip.panelExpanded">
<BetSlipPanel />
</div>
</aside>
</template>
<style scoped>
.desktop-betslip-rail {
position: relative;
flex-shrink: 0;
width: var(--desktop-right-betslip-w);
min-width: 0;
border-left: 1px solid var(--border);
background: var(--bg-card);
display: flex;
flex-direction: column;
min-height: 0;
transition: width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
overflow: visible;
}
.rail-toggle {
position: absolute;
left: -18px;
top: 50%;
z-index: 30;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card);
color: var(--primary-light);
cursor: pointer;
display: grid;
place-items: center;
transform: translateY(-50%);
box-shadow:
0 2px 10px rgba(0, 61, 107, 0.12),
0 0 0 3px rgba(255, 255, 255, 0.92);
transition:
transform 0.22s cubic-bezier(0.4, 0, 0.2, 1),
background 0.2s,
color 0.2s,
border-color 0.2s,
box-shadow 0.2s;
}
.rail-toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.rail-toggle.is-collapsed .rail-toggle-icon {
transform: rotate(180deg);
}
.rail-toggle:hover {
border-color: var(--border-active);
background: var(--primary);
color: #fff;
box-shadow:
0 4px 14px rgba(0, 61, 107, 0.2),
0 0 0 3px rgba(0, 102, 204, 0.1);
}
.rail-toggle:active {
transform: translateY(-50%) scale(0.94);
}
.rail-toggle:focus-visible {
outline: 2px solid var(--border-active);
outline-offset: 2px;
}
.rail-content {
flex: 1;
min-height: 0;
width: var(--desktop-right-betslip-expanded-w, 288px);
overflow: hidden;
}
.desktop-betslip-rail.is-collapsed .rail-content {
visibility: hidden;
}
.rail-collapsed-hint {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px 0;
border: none;
background: transparent;
cursor: pointer;
transition: background 0.2s;
}
.rail-collapsed-hint:hover {
background: var(--bg-hover);
}
.rail-label {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 12px;
font-weight: 800;
color: var(--primary);
letter-spacing: 0.12em;
}
.rail-badge {
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: var(--odds-accent, #F8971F);
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 20px;
text-align: center;
}
</style>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
@@ -243,7 +243,7 @@ function addToParlayList() {
padding: 12px 28px 12px 12px;
border-radius: 8px;
border: 1px solid var(--border-active);
background: rgba(18, 18, 18, 0.98);
background: var(--bg-card);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
}
@@ -261,7 +261,7 @@ function addToParlayList() {
}
.pop-close:hover {
color: #fff;
color: var(--primary);
}
.pop-match {
@@ -292,13 +292,13 @@ function addToParlayList() {
.pick {
font-size: 12px;
font-weight: 700;
color: #fff;
color: var(--text);
}
.odds {
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
color: var(--primary);
}
.stake-label {
@@ -315,8 +315,8 @@ function addToParlayList() {
margin-bottom: 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: #0d0d0d;
color: #fff;
background: var(--bg-card);
color: var(--text);
font-size: 12px;
}

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { OutrightEvent } from '../outright/OutrightEventSection.vue';
@@ -47,7 +47,7 @@ const isSettled = computed(
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(20, 20, 20, 0.95);
background: var(--bg-card);
text-align: left;
transition: border-color 0.2s, background 0.2s;
}
@@ -81,7 +81,7 @@ const isSettled = computed(
.settled-tag {
font-size: 10px;
font-weight: 800;
color: #c9a227;
color: var(--odds-accent);
border: 1px solid rgba(201, 162, 39, 0.45);
border-radius: 999px;
padding: 1px 7px;

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { useRoute, RouterView } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -7,14 +7,16 @@ 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 DesktopBetSlipRail from './DesktopBetSlipRail.vue';
import FloatingMailbox from '../FloatingMailbox.vue';
import { usePlayerHome } from '../../composables/usePlayerHome';
import { useAuthStore } from '../../stores/auth';
import { useBetSlipStore } from '../../stores/betSlip';
const route = useRoute();
const { locale } = useI18n();
const auth = useAuthStore();
const slip = useBetSlipStore();
const { load: loadPlayerHome } = usePlayerHome();
const layoutMode = computed<'betting' | 'account' | 'hub' | 'marketing'>(() => {
@@ -26,7 +28,7 @@ const layoutMode = computed<'betting' | 'account' | 'hub' | 'marketing'>(() => {
p === '/bets' ||
p.startsWith('/bets/') ||
p.startsWith('/wallet') ||
p === '/profile/cashbacks'
p.startsWith('/profile')
) {
return 'account';
}
@@ -44,6 +46,13 @@ const isBettingDetail = computed(
() => route.path.startsWith('/match/') || route.path.startsWith('/outright/'),
);
const showBetSlipRail = computed(() => {
const p = route.path;
if (p === '/bets' || p.startsWith('/bets/')) return false;
if (p.startsWith('/wallet') || p.startsWith('/profile')) return false;
return true;
});
onMounted(() => {
void loadPlayerHome(true);
});
@@ -61,11 +70,18 @@ watch(
</script>
<template>
<div class="desktop-shell">
<div
class="desktop-shell"
:class="{
'betslip-collapsed': showBetSlipRail && !slip.panelExpanded,
'betslip-hidden': !showBetSlipRail,
}"
>
<header class="desktop-header-wrap">
<DesktopTopNav />
</header>
<div class="desktop-body">
<div class="desktop-main-container">
<!-- Betting Layout -->
<div v-if="layoutMode === 'betting'" class="desktop-layout-betting-wrap">
@@ -85,9 +101,6 @@ watch(
<component v-else :is="Component" :key="viewRoute.fullPath" />
</RouterView>
</main>
<aside class="desktop-betting-right">
<BetSlipPanel />
</aside>
</div>
</div>
@@ -114,7 +127,7 @@ watch(
</div>
<!-- Marketing/Single Column Layout -->
<div v-else class="desktop-layout-marketing">
<div v-else class="desktop-layout-marketing no-scrollbar">
<main class="desktop-marketing-content">
<RouterView v-slot="{ Component, route: viewRoute }">
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
@@ -125,6 +138,8 @@ watch(
</main>
</div>
</div>
<DesktopBetSlipRail v-if="showBetSlipRail" />
</div>
<FloatingMailbox />
<DesktopOddsBetPopover />
</div>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -16,8 +16,7 @@ const activeTab = computed(() => {
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('/wallet') || p.startsWith('/profile')) return 'my_wallet';
if (p.startsWith('/messages')) return 'messages';
return '';
});
@@ -57,17 +56,9 @@ const activeTab = computed(() => {
<RouterLink
to="/wallet"
class="nav-link"
:class="{ active: activeTab === 'wallet' }"
:class="{ active: activeTab === 'my_wallet' }"
>
{{ t('nav.wallet') }}
</RouterLink>
<RouterLink
to="/profile"
class="nav-link"
:class="{ active: activeTab === 'profile' }"
>
{{ t('nav.profile') }}
{{ t('nav.my_wallet') }}
</RouterLink>
</nav>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { RouterLink } from 'vue-router';
import DesktopWalletSubNav from './DesktopWalletSubNav.vue';
defineProps<{
showRecharge?: boolean;
}>();
const { t } = useI18n();
</script>
<template>
<header class="desktop-records-header wallet-page-header">
<div class="wallet-page-header-main">
<DesktopWalletSubNav />
</div>
<div class="desktop-records-actions">
<slot name="actions">
<RouterLink
v-if="showRecharge !== false"
to="/wallet/recharge"
class="desktop-records-action-btn"
>
{{ t('wallet.recharge_btn') }}
</RouterLink>
</slot>
</div>
</header>
</template>
<style scoped>
.wallet-page-header {
align-items: center;
}
.wallet-page-header-main {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
</style>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -9,12 +9,15 @@ const route = useRoute();
const tabs = [
{
path: '/wallet',
labelKey: 'nav.wallet',
labelKey: 'wallet.my_balance',
match: (p: string) => p === '/wallet',
},
{
path: '/wallet/detail',
labelKey: 'wallet.view_all',
match: (p: string) =>
p === '/wallet' ||
p === '/wallet/detail' ||
p === '/wallet/recharge' ||
p.startsWith('/wallet/transactions'),
p.startsWith('/wallet/transactions/'),
},
{
path: '/wallet/recharge/history',
@@ -32,7 +35,7 @@ const activePath = computed(() => route.path);
</script>
<template>
<nav class="desktop-wallet-subnav" :aria-label="t('nav.wallet')">
<nav class="desktop-wallet-subnav" :aria-label="t('nav.my_wallet')">
<RouterLink
v-for="tab in tabs"
:key="tab.path"

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMatchFilters } from '../../composables/useMatchFilters';
@@ -10,7 +10,7 @@ import {
const { t } = useI18n();
const { parlayMatches, loadParlayMatches } = useDesktopParlayMatches();
const { searchQuery, filterState, toggleLeague, clearFilters } = useMatchFilters();
const { searchQuery, filterState, toggleLeague, clearFilters, isLeagueSelected } = useMatchFilters();
onMounted(() => {
void loadParlayMatches();
@@ -62,7 +62,12 @@ const availableLeagues = computed(() => {
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
});
const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(leagueId);
const isSelected = (leagueId: string) =>
isLeagueSelected(leagueId, filterState.value.leagueIds);
function onToggleLeague(leagueId: string) {
toggleLeague(leagueId, availableLeagues.value.map((lg) => lg.id));
}
</script>
<template>
@@ -110,7 +115,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
<div class="section-hdr leagues-hdr">
<span>{{ t('bet.filter_league') }}</span>
<button
v-if="filterState.leagueIds.length > 0"
v-if="filterState.leagueIds.length > 0 || filterState.time !== 'all' || searchQuery.trim()"
type="button"
class="clear-btn"
@click="clearFilters"
@@ -124,7 +129,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
:key="lg.id"
class="league-item"
:class="{ active: isSelected(lg.id) }"
@click="toggleLeague(lg.id)"
@click="onToggleLeague(lg.id)"
>
<div class="checkbox" :class="{ checked: isSelected(lg.id) }"></div>
<span class="league-name" :title="lg.name">{{ lg.name }}</span>
@@ -153,7 +158,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
.sidebar-search-input {
width: 100%;
background: #0d0d0d !important;
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
@@ -187,7 +192,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
.time-btn {
flex: 1;
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 5px 2px;
@@ -266,7 +271,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
border-radius: 2px;
position: relative;
flex-shrink: 0;
background: #0d0d0d;
background: var(--bg-card);
}
.checkbox.checked {
@@ -281,7 +286,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
left: 4px;
width: 3px;
height: 6px;
border: solid #111;
border: solid var(--bg-card);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
@@ -290,7 +295,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
flex: 1;
font-size: 11px;
font-weight: 600;
color: #ddd;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -305,7 +310,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
font-size: 9px;
color: var(--text-muted);
font-weight: 700;
background: #181818;
background: var(--bg-body);
padding: 1px 5px;
border-radius: 8px;
}

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -160,7 +160,7 @@ watch(currentMatchId, scrollToActive);
.sidebar-search-input {
width: 100%;
background: #0d0d0d !important;
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
@@ -200,7 +200,7 @@ watch(currentMatchId, scrollToActive);
.time-btn {
flex: 1;
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 5px 2px;
@@ -274,7 +274,7 @@ watch(currentMatchId, scrollToActive);
.matches-list :deep(.team-name) {
font-size: 12px;
font-weight: 700;
color: #ffffff;
color: var(--text);
text-align: center;
padding: 0 4px;
max-width: 80px;
@@ -323,7 +323,7 @@ watch(currentMatchId, scrollToActive);
.matches-list :deep(.match-card) {
padding: 10px 8px;
min-height: unset;
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 6px;
gap: 6px;

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -120,7 +120,7 @@ function onPageSizeChange(e: Event) {
align-items: center;
border-radius: 6px;
border: 1px solid var(--border);
background: #141414;
background: var(--bg-body);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
@@ -137,7 +137,7 @@ function onPageSizeChange(e: Event) {
.page-btn.active {
background: var(--primary);
border-color: var(--primary);
color: #111;
color: var(--bg-card);
font-weight: 800;
}
@@ -154,7 +154,7 @@ function onPageSizeChange(e: Event) {
.page-size-select {
height: 32px;
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 6px;

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -64,7 +64,7 @@ function selectSport(id: SportCategory, enabled: boolean) {
padding: 7px 12px;
border-radius: 8px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.03);
background: rgba(0, 0, 0, 0.04);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
@@ -72,8 +72,8 @@ function selectSport(id: SportCategory, enabled: boolean) {
}
.sport-category:hover:not(:disabled) {
color: #fff;
background: rgba(255, 255, 255, 0.06);
color: var(--primary);
background: var(--bg-hover);
}
.sport-category.active {
@@ -100,9 +100,9 @@ function selectSport(id: SportCategory, enabled: boolean) {
.sport-category-soon {
padding: 1px 5px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.04);
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.45);
color: rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -121,46 +121,54 @@ function formatOdds(odds: string) {
gap: 3px;
min-height: 42px;
padding: 6px 3px;
background: #161616;
border: 1px solid #282828;
border-radius: 3px;
background: #FFFFFF;
border: 1px solid #CBD5E1;
border-radius: 8px;
text-align: center;
transition: border-color 0.15s ease, background 0.15s ease;
cursor: pointer;
}
.score-card:hover:not(.score-card--locked) {
border-color: var(--primary-light);
background: var(--bg-hover);
}
.score-card.selected {
border-color: var(--primary);
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);
border-width: 2px;
background: rgba(0, 61, 107, 0.08);
}
.score-card.selected .score-line {
color: #e8d48a;
color: var(--primary);
}
.score-card.selected .odds {
color: #f0d875;
color: var(--odds-accent);
}
.score-card--locked {
background: #111;
border-color: #333;
background: #E8EDF2;
border-color: #E5E7EB;
cursor: not-allowed;
}
.score-card--locked .score-line,
.score-card--locked .odds {
color: #666;
color: #6B7280;
}
.score-line {
font-size: 11px;
font-weight: 800;
color: var(--text);
color: #1A1A2E;
}
.odds {
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
color: var(--odds-accent);
}
.cs-panel--dense {
@@ -184,6 +192,7 @@ function formatOdds(odds: string) {
min-height: 34px;
padding: 4px 2px;
gap: 2px;
border-radius: 4px;
}
.cs-panel--dense .score-line {

View File

@@ -79,7 +79,7 @@ const panelStyle = computed(() =>
<style scoped>
.wrap {
padding: 6px 8px 8px;
background: #0c0c0c;
background: #F5F7FA;
}
.wrap.compact {
@@ -184,46 +184,54 @@ const panelStyle = computed(() =>
gap: 2px;
min-height: 44px;
padding: 6px 4px;
border-radius: 4px;
background: #141414;
border: 1px solid #262626;
border-radius: 8px;
background: #FFFFFF;
border: 1px solid #CBD5E1;
text-align: center;
transition: border-color 0.15s ease, background 0.15s ease;
cursor: pointer;
}
.odds-btn:hover:not(.odds-btn--locked) {
border-color: var(--primary-light);
background: var(--bg-hover);
}
.odds-btn.selected {
border-color: var(--primary);
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);
border-width: 2px;
background: rgba(0, 61, 107, 0.08);
}
.odds-btn.selected .label {
color: #e8d48a;
color: var(--primary);
}
.odds-btn.selected .odds {
color: #f0d875;
color: var(--odds-accent);
}
.odds-btn--locked {
background: #111;
border-color: #333;
background: #E8EDF2;
border-color: #E5E7EB;
cursor: not-allowed;
}
.odds-btn--locked .label,
.odds-btn--locked .odds {
color: #666;
color: #6B7280;
}
.label {
font-size: 9px;
color: var(--text-muted);
font-size: 10px;
color: #4B5563;
line-height: 1.15;
font-weight: 600;
}
.odds {
font-size: 14px;
font-size: 15px;
font-weight: 800;
color: var(--primary-light);
color: var(--odds-accent);
}
</style>

View File

@@ -437,12 +437,12 @@ function formatOdds(odds: string) {
position: relative;
width: 100%;
max-width: 300px;
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
border: 1px solid var(--border-gold-soft);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 16px 16px;
text-align: center;
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
box-shadow: var(--shadow-md);
}
.close-x {
@@ -452,7 +452,7 @@ function formatOdds(odds: string) {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
background: var(--bg-hover);
color: var(--text-muted);
font-size: 14px;
line-height: 1;
@@ -577,7 +577,7 @@ function formatOdds(odds: string) {
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
background: var(--bg-card);
color: var(--text);
font-size: 18px;
font-weight: 800;
@@ -586,12 +586,12 @@ function formatOdds(odds: string) {
}
.stake-input:focus {
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.15);
border-color: var(--border-active);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.stake-input::placeholder {
color: #555;
color: var(--text-muted);
font-weight: 500;
font-size: 14px;
}
@@ -607,16 +607,16 @@ function formatOdds(odds: string) {
padding: 6px 4px;
border-radius: 6px;
border: 1px solid var(--border);
background: #141414;
background: var(--bg-hover);
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
}
.chip:active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
border-color: var(--border-active);
color: var(--primary);
background: rgba(0, 102, 204, 0.08);
}
.est-return {

View File

@@ -132,8 +132,8 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
width: 26px;
height: 26px;
border-radius: 50%;
background: #141414;
border: 1px solid var(--border-gold-soft);
background: var(--bg-hover);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
@@ -170,8 +170,8 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
color: #c9a227;
border: 1px solid rgba(201, 162, 39, 0.45);
color: var(--odds-accent);
border: 1px solid rgba(248, 151, 31, 0.45);
border-radius: 999px;
padding: 1px 7px;
line-height: 1.4;

View File

@@ -149,7 +149,7 @@ function closeModal() {
border: 1px solid rgba(201, 162, 39, 0.22);
font-size: 12px;
font-weight: 600;
color: #c9a227;
color: var(--odds-accent);
line-height: 1.45;
}

View File

@@ -1,12 +1,24 @@
import { ref } from 'vue';
import { ref, watch } from 'vue';
const searchQuery = ref('');
const filterState = ref({
time: 'all' as 'all' | 'today' | 'early',
status: 'open' as 'all' | 'open' | 'settled',
leagueIds: [] as string[]
leagueIds: [] as string[],
});
/** 空数组表示未限定联赛,等价于全部选中 */
export function isLeagueSelected(leagueId: string, leagueIds: string[]) {
return leagueIds.length === 0 || leagueIds.includes(leagueId);
}
watch(
() => filterState.value.time,
() => {
filterState.value.leagueIds = [];
},
);
export function useMatchFilters() {
function clearFilters() {
searchQuery.value = '';
@@ -15,12 +27,23 @@ export function useMatchFilters() {
filterState.value.leagueIds = [];
}
function toggleLeague(leagueId: string) {
const idx = filterState.value.leagueIds.indexOf(leagueId);
function toggleLeague(leagueId: string, availableIds: string[]) {
const current = filterState.value.leagueIds;
if (current.length === 0) {
filterState.value.leagueIds = availableIds.filter((id) => id !== leagueId);
return;
}
const idx = current.indexOf(leagueId);
if (idx > -1) {
filterState.value.leagueIds.splice(idx, 1);
} else {
filterState.value.leagueIds.push(leagueId);
current.splice(idx, 1);
return;
}
current.push(leagueId);
if (availableIds.every((id) => current.includes(id))) {
filterState.value.leagueIds = [];
}
}
@@ -29,5 +52,6 @@ export function useMatchFilters() {
filterState,
clearFilters,
toggleLeague,
isLeagueSelected,
};
}

View File

@@ -6,6 +6,27 @@ import { resolveBanners } from '../constants/defaultBanner';
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
import { stripHtml } from '../utils/html';
export interface PlayerHomeMatchSelection {
id: string;
selectionCode?: string;
selectionName: string;
selectionDisplayName?: string;
odds: string;
status?: string;
oddsVersion?: string;
}
export interface PlayerHomeMatchMarket {
id: string;
marketType: string;
marketDisplayName?: string;
lineValue?: number | null;
status: string;
allowSingle?: boolean;
allowParlay?: boolean;
selections: PlayerHomeMatchSelection[];
}
export interface PlayerHomeMatch {
id: string;
leagueName?: string;
@@ -19,6 +40,8 @@ export interface PlayerHomeMatch {
startTime: string;
isHot?: boolean;
displayOrder?: number;
bettingOpen?: boolean;
markets?: PlayerHomeMatchMarket[];
}
export interface PlayerContentItem {

View File

@@ -0,0 +1,45 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const FALLBACK_RULES: Array<{ test: (path: string) => boolean; fallback: string }> = [
{ test: (p) => /^\/wallet\/transactions\//.test(p), fallback: '/wallet/detail' },
{ test: (p) => p.startsWith('/wallet/recharge/history'), fallback: '/wallet' },
{ test: (p) => p === '/wallet/recharge', fallback: '/wallet' },
{ test: (p) => p === '/wallet/detail', fallback: '/wallet' },
{ test: (p) => p === '/wallet/cashbacks' || p === '/profile/cashbacks', fallback: '/wallet' },
{ test: (p) => p === '/profile/edit', fallback: '/wallet' },
{ test: (p) => p.startsWith('/bets/'), fallback: '/bets' },
{ test: (p) => p.startsWith('/match/'), fallback: '/bet' },
{ test: (p) => p.startsWith('/outright/'), fallback: '/bet' },
{ test: (p) => p.startsWith('/announcements/'), fallback: '/announcements' },
{ test: (p) => p.startsWith('/messages/'), fallback: '/messages' },
{ test: (p) => p === '/wallet', fallback: '/' },
{ test: (p) => p === '/bets', fallback: '/' },
];
export function resolveSmartBackFallback(path: string): string {
for (const rule of FALLBACK_RULES) {
if (rule.test(path)) return rule.fallback;
}
return '/';
}
export function useSmartBack(fallback?: MaybeRefOrGetter<string | undefined>) {
const router = useRouter();
const route = useRoute();
const resolvedFallback = computed(
() => toValue(fallback) ?? resolveSmartBackFallback(route.path),
);
function goBack() {
const state = window.history.state as { back?: string | null } | null;
if (state?.back != null) {
router.back();
return;
}
void router.push(resolvedFallback.value);
}
return { goBack, fallback: resolvedFallback };
}

View File

@@ -29,7 +29,7 @@ export default {
total: '{total} total',
per_page: '{size} / page',
},
nav: { home: 'Home', sports: 'Sports', football: 'Football', bet: 'Bet', bet_history: 'History', wallet: 'Transactions', account_hub: 'Bets & Wallet', profile: 'Profile', search: 'Search teams, leagues…', announcements: 'Announcements', sports_more_soon: 'More sports coming soon' },
nav: { home: 'Home', sports: 'Sports', football: 'Football', bet: 'Bet', bet_history: 'History', wallet: 'Transactions', my_wallet: 'My Wallet', account_hub: 'Bets & Wallet', profile: 'Profile', search: 'Search teams, leagues…', announcements: 'Announcements', sports_more_soon: 'More sports coming soon' },
home: {
hot_matches: 'Hot matches',
hot_tab: 'Hot',
@@ -230,6 +230,7 @@ export default {
},
wallet: {
balance: 'Balance',
my_balance: 'My Balance',
cash_balance: 'Cash Balance',
card_holder: 'Cardholder',
unsettled: 'Unsettled',
@@ -266,6 +267,9 @@ export default {
filter_bet: 'Bet',
filter_cashback: 'Cashback',
view_all: 'View all transactions',
recent_title: 'Recent transactions',
overview: 'Overview',
all_records: 'All transactions',
detail_summary: 'Details',
detail_amount: 'Amount',
detail_balance_before: 'Balance Before',
@@ -397,7 +401,7 @@ export default {
sport_coming_soon: 'Coming soon',
tab_today: 'Today',
tab_early: 'Early',
filter_time: 'Time',
filter_time: 'Filter',
filter_league: 'Leagues',
filter_market: 'Markets',
time_all: 'All',

View File

@@ -36,6 +36,7 @@ export default {
bet: 'Pertaruhan',
bet_history: 'Sejarah',
wallet: 'Transaksi',
my_wallet: 'Dompet Saya',
account_hub: 'Pertaruhan & Bil',
profile: 'Profil',
search: 'Cari pasukan, liga…',
@@ -242,6 +243,7 @@ export default {
},
wallet: {
balance: 'Baki',
my_balance: 'Baki Saya',
cash_balance: 'Baki Tunai',
card_holder: 'Pemegang',
unsettled: 'Belum Selesai',
@@ -278,6 +280,9 @@ export default {
filter_bet: 'Pertaruhan',
filter_cashback: 'Rebat',
view_all: 'Lihat semua transaksi',
recent_title: 'Transaksi terkini',
overview: 'Gambaran akaun',
all_records: 'Semua transaksi',
detail_summary: 'Butiran',
detail_amount: 'Jumlah',
detail_balance_before: 'Baki Sebelum',
@@ -409,7 +414,7 @@ export default {
sport_coming_soon: 'Akan datang',
tab_today: 'Hari Ini',
tab_early: 'Awal',
filter_time: 'Masa',
filter_time: 'Tapis',
filter_league: 'Liga',
filter_market: 'Pasaran',
time_all: 'Semua',

View File

@@ -29,7 +29,7 @@ export default {
total: '共 {total} 条',
per_page: '{size} / 页',
},
nav: { home: '主页', sports: '体育赛事', football: '足球', bet: '投注', bet_history: '历史投注', wallet: '资金明细', account_hub: '投注账单', profile: '我的', search: '搜索球队、联赛…', announcements: '公告', sports_more_soon: '更多体育,敬请期待' },
nav: { home: '主页', sports: '体育赛事', football: '足球', bet: '投注', bet_history: '历史投注', wallet: '资金明细', my_wallet: '我的钱包', account_hub: '投注账单', profile: '我的', search: '搜索球队、联赛…', announcements: '公告', sports_more_soon: '更多体育,敬请期待' },
home: {
hot_matches: '热门赛事',
hot_tab: '热门',
@@ -230,6 +230,7 @@ export default {
},
wallet: {
balance: '余额',
my_balance: '我的余额',
cash_balance: '现金余额',
card_holder: '持卡人',
unsettled: '未结算',
@@ -266,6 +267,9 @@ export default {
filter_bet: '投注',
filter_cashback: '反水',
view_all: '查看全部账单',
recent_title: '最近账单',
overview: '账户概览',
all_records: '全部账单',
detail_summary: '账务明细',
detail_amount: '变动金额',
detail_balance_before: '变动前余额',
@@ -397,7 +401,7 @@ export default {
sport_coming_soon: '即将上线',
tab_today: '今日',
tab_early: '早盘',
filter_time: '时间筛选',
filter_time: '筛选',
filter_league: '联赛',
filter_market: '玩法筛选',
time_all: '全部',

View File

@@ -58,7 +58,7 @@ const showBottomNav = computed(() => {
p.startsWith('/match/') ||
p === '/bets' ||
p === '/wallet' ||
p === '/profile'
p.startsWith('/wallet/')
) return true;
// 邮箱关闭时客服页保留底部导航,避免用户无法离开
if (!inboxEnabled.value && p === '/messages') return true;
@@ -108,7 +108,7 @@ watch(
},
);
const balanceRefreshPaths = ['/profile', '/wallet', '/bets'];
const balanceRefreshPaths = ['/wallet', '/bets'];
let mainScrollEl: HTMLElement | null = null;
watch(locale, (next, prev) => {
@@ -241,13 +241,13 @@ watch(
<BottomNavIcon name="history" />
<span class="nav-label">{{ t('nav.bet_history') }}</span>
</RouterLink>
<RouterLink to="/wallet" class="nav-item" :class="{ active: route.path === '/wallet' }">
<RouterLink
to="/wallet"
class="nav-item"
:class="{ active: route.path === '/wallet' || route.path.startsWith('/wallet/') }"
>
<BottomNavIcon name="wallet" />
<span class="nav-label">{{ t('nav.wallet') }}</span>
</RouterLink>
<RouterLink to="/profile" class="nav-item" :class="{ active: route.path.startsWith('/profile') }">
<BottomNavIcon name="profile" />
<span class="nav-label">{{ t('nav.profile') }}</span>
<span class="nav-label">{{ t('nav.my_wallet') }}</span>
</RouterLink>
</nav>

View File

@@ -43,8 +43,8 @@ const router = createRouter({
{ path: 'wallet/recharge', component: () => import('../views/RechargeView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/recharge/history', component: () => import('../views/RechargeHistoryView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue'), meta: { requiresAuth: true } },
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true, requiresAuth: true } },
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
{ path: 'profile', redirect: '/wallet' },
{ path: 'profile/cashbacks', redirect: '/wallet/cashbacks' },
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue'), meta: { requiresAuth: true } },
],
},

View File

@@ -27,6 +27,23 @@ export type SlipMode = 'single' | 'parlay';
export type ParlaySlipError = ParlayRejectReason | 'MAX_LEGS' | 'SAME_MATCH';
const BET_SLIP_STORAGE_PREFIX = 'player:betSlip:v1';
const DESKTOP_PANEL_EXPANDED_KEY = 'player:desktopBetSlipExpanded:v1';
function readDesktopPanelExpanded() {
try {
return localStorage.getItem(DESKTOP_PANEL_EXPANDED_KEY) !== '0';
} catch {
return true;
}
}
function writeDesktopPanelExpanded(expanded: boolean) {
try {
localStorage.setItem(DESKTOP_PANEL_EXPANDED_KEY, expanded ? '1' : '0');
} catch {
// ignore quota / private mode errors
}
}
interface BetSlipPersisted {
singleItem: SlipItem | null;
@@ -370,6 +387,11 @@ export const useBetSlipStore = defineStore('betSlip', () => {
);
const drawerOpen = ref(false);
const panelExpanded = ref(readDesktopPanelExpanded());
watch(panelExpanded, (expanded) => {
writeDesktopPanelExpanded(expanded);
});
function openDrawer() {
drawerOpen.value = true;
@@ -379,6 +401,18 @@ export const useBetSlipStore = defineStore('betSlip', () => {
drawerOpen.value = false;
}
function expandPanel() {
panelExpanded.value = true;
}
function collapsePanel() {
panelExpanded.value = false;
}
function togglePanelExpanded() {
panelExpanded.value = !panelExpanded.value;
}
function updateSelectionOdds(selectionId: string, odds: number, oddsVersion: string) {
if (singleItem.value?.selectionId === selectionId) {
singleItem.value = { ...singleItem.value, odds, oddsVersion };
@@ -414,6 +448,7 @@ export const useBetSlipStore = defineStore('betSlip', () => {
canSubmit,
lastParlayError,
drawerOpen,
panelExpanded,
setMode,
setSingleItem,
addItem,
@@ -428,6 +463,9 @@ export const useBetSlipStore = defineStore('betSlip', () => {
clearAll,
openDrawer,
closeDrawer,
expandPanel,
collapsePanel,
togglePanelExpanded,
updateSelectionOdds,
getItemStake,
setItemStake,

View File

@@ -31,6 +31,10 @@
--gradient-gold: linear-gradient(180deg, #F0F4F8 0%, #FFFFFF 100%);
--gradient-gold-border: linear-gradient(180deg, #E5E7EB 0%, #FFFFFF 100%);
--gradient-card: linear-gradient(160deg, #FFFFFF 0%, #F5F7FA 100%);
--space-card: 16px;
--space-section: 12px;
--surface-accent: rgba(0, 102, 204, 0.06);
--border-gold-soft: rgba(0, 102, 204, 0.2);
--player-header-h: 48px;
--player-bottom-nav-h: 54px;
--safe-top: env(safe-area-inset-top, 0px);
@@ -62,11 +66,6 @@ body {
@media (min-width: 1024px) {
body {
background-color: var(--bg-body);
background-image: url('./assets/images/pcbg-light.webp');
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
background-attachment: fixed;
}
}

View File

@@ -9,6 +9,19 @@
}
/* Scrollbar customizations */
.hide-scrollbar,
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar,
.no-scrollbar::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.desktop-shell ::-webkit-scrollbar {
width: 6px;
height: 6px;

View File

@@ -5,10 +5,6 @@
display: flex;
flex-direction: column;
background-color: var(--desktop-bg);
background-image: url('../../assets/images/pcbg-light.webp');
background-size: cover;
background-position: center;
background-attachment: fixed;
overflow: hidden;
}
@@ -31,10 +27,26 @@
background: var(--bg-card);
}
.desktop-shell.betslip-hidden {
--desktop-right-betslip-w: 0px;
}
.desktop-shell.betslip-collapsed {
--desktop-right-betslip-w: var(--desktop-right-betslip-collapsed-w);
}
.desktop-body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.desktop-main-container {
flex: 1;
display: flex;
min-height: 0;
min-width: 0;
overflow: hidden;
position: relative;
}
@@ -58,7 +70,7 @@
.desktop-layout-betting {
display: grid;
grid-template-columns: var(--desktop-left-sidebar-w) 1fr var(--desktop-right-betslip-w);
grid-template-columns: var(--desktop-left-sidebar-w) 1fr;
flex: 1;
width: 100%;
min-height: 0;
@@ -80,12 +92,6 @@
padding: 0;
}
.desktop-betting-right {
border-left: 1px solid var(--border);
background: var(--bg-card);
overflow-y: auto;
}
/* Account Layout: full-width records center */
.desktop-layout-account {
display: flex;
@@ -101,8 +107,10 @@
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
overflow-y: auto;
overflow-x: hidden;
padding: 28px 40px 40px;
align-items: stretch;
}
.desktop-account-center > .desktop-records-page {
@@ -143,9 +151,10 @@
/* Hub Layout: Left Sidebar (List) + Right Outlet (Detail) */
.desktop-layout-hub {
display: grid;
grid-template-columns: 320px 1fr;
grid-template-columns: 360px 1fr;
width: 100%;
height: 100%;
min-height: 0;
}
.desktop-hub-left {
@@ -154,6 +163,13 @@
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.desktop-hub-left > * {
flex: 1;
min-height: 0;
min-width: 0;
}
.desktop-hub-right {

View File

@@ -38,6 +38,8 @@
--desktop-header-h: 56px;
--desktop-left-sidebar-w: 220px;
--desktop-right-betslip-w: 288px;
--desktop-right-betslip-expanded-w: 288px;
--desktop-right-betslip-collapsed-w: 40px;
/* Reset typography inside desktop */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;

View File

@@ -13,6 +13,8 @@ const FALLBACK_IMG = '/uploads/banners/welcome.svg';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const { embedded = false } = defineProps<{ embedded?: boolean }>();
const { announcementItems, bannerItems, loading, load } = usePlayerHome();
const announcementId = computed(() => String(route.params.id ?? ''));
@@ -105,19 +107,26 @@ watch(announcementId, () => {
</script>
<template>
<div class="announce-detail">
<header class="page-header">
<div class="announce-detail" :class="{ 'is-embedded': embedded }">
<header v-if="!embedded" class="page-header">
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack"></button>
<h1>{{ t('announcements.detail_title') }}</h1>
</header>
<header v-else class="page-header page-header--hub">
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goList">
{{ t('announcements.view_all') }}
</button>
<h1>{{ t('announcements.detail_title') }}</h1>
</header>
<div v-if="loading && !item" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="!item" class="empty">
<p>{{ t('announcements.not_found') }}</p>
<button type="button" class="link-btn" @click="goList">{{ t('announcements.view_all') }}</button>
<button v-if="!embedded" type="button" class="link-btn" @click="goList">{{ t('announcements.view_all') }}</button>
</div>
<article v-else class="detail-article">
@@ -320,4 +329,28 @@ watch(announcementId, () => {
border: 1px solid var(--border-active);
color: var(--primary);
}
.announce-detail.is-embedded {
padding: 0 4px 24px;
}
.is-embedded .page-header--hub {
padding: 0 0 16px;
border-bottom: 1px solid var(--border);
}
.is-embedded .page-header--hub h1 {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--primary-dark, #002847);
}
.is-embedded .detail-title {
font-size: 22px;
}
.is-embedded .detail-body-wrap {
padding-top: 12px;
}
</style>

View File

@@ -1,12 +1,15 @@
<script setup lang="ts">
import { computed, onActivated } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePlayerHome } from '../composables/usePlayerHome';
import { stripHtml } from '../utils/html';
const { embedded = false } = defineProps<{ embedded?: boolean }>();
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const { announcementItems, loading, load } = usePlayerHome();
@@ -39,15 +42,27 @@ function itemPreview(item: (typeof items.value)[number]) {
return '';
}
function isActive(id: string) {
return embedded && String(route.params.id ?? '') === id;
}
function onBack() {
if (embedded) {
router.push('/');
return;
}
goBack();
}
onActivated(() => {
void load(true);
});
</script>
<template>
<div class="announce-page">
<div class="announce-page" :class="{ 'is-embedded': embedded }">
<header class="page-header">
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack"></button>
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="onBack"></button>
<h1>{{ t('announcements.title') }}</h1>
</header>
@@ -65,6 +80,7 @@ onActivated(() => {
:key="item.id"
type="button"
class="list-row"
:class="{ 'is-active': isActive(item.id) }"
@click="openDetail(item.id)"
>
<span class="row-main">
@@ -176,4 +192,101 @@ onActivated(() => {
color: var(--text-muted);
line-height: 1;
}
/* Desktop hub sidebar */
.announce-page.is-embedded {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 16px 16px 0;
box-sizing: border-box;
}
.is-embedded .page-header {
flex-shrink: 0;
padding: 0 4px 12px;
margin-bottom: 4px;
}
.is-embedded .page-header h1 {
font-size: 16px;
font-weight: 800;
}
.is-embedded .back-btn {
width: 32px;
height: 32px;
font-size: 20px;
border-radius: 8px;
}
.is-embedded .list {
flex: 1;
min-height: 0;
overflow-y: auto;
margin: 0 -4px;
padding: 0 4px 16px;
}
.is-embedded .list-row {
align-items: flex-start;
gap: 10px;
padding: 12px 12px;
margin-bottom: 4px;
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px;
transition: background 0.15s, border-color 0.15s;
}
.is-embedded .list-row:hover {
background: var(--bg-hover);
}
.is-embedded .list-row.is-active {
background: rgba(0, 102, 204, 0.08);
border-color: var(--border-active);
}
.is-embedded .list-row.is-active .title {
color: var(--primary);
}
.is-embedded .list-row.is-active .chevron {
color: var(--primary);
}
.is-embedded .title {
font-size: 13px;
font-weight: 700;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
}
.is-embedded .preview {
margin-top: 4px;
font-size: 12px;
line-height: 1.45;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.is-embedded .date {
margin-top: 6px;
font-size: 11px;
}
.is-embedded .chevron {
margin-top: 2px;
font-size: 16px;
align-self: center;
}
</style>

View File

@@ -15,13 +15,26 @@ import {
} from '../composables/usePlayerMessages';
import { useInboxFeature } from '../composables/useInboxFeature';
const props = withDefaults(
defineProps<{
embedded?: boolean;
/** 悬浮信箱内嵌时使用,不走路由 */
id?: string;
}>(),
{ embedded: false },
);
const emit = defineEmits<{ back: [] }>();
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const { loadMessageDetail, markMessageRead, deleteMessage } = usePlayerMessages();
const { hubRoute } = useInboxFeature();
const messageId = computed(() => String(route.params.id ?? ''));
const messageId = computed(() => props.id ?? String(route.params.id ?? ''));
const isPopupEmbedded = computed(() => props.embedded && Boolean(props.id));
const isHubEmbedded = computed(() => props.embedded && !props.id);
const message = ref<PlayerMessage | null>(null);
const loading = ref(false);
const error = ref(false);
@@ -49,10 +62,22 @@ const contentPromoId = computed(() => {
});
function goBack() {
if (isPopupEmbedded.value) {
emit('back');
return;
}
if (isHubEmbedded.value) {
router.push(hubRoute.value);
return;
}
router.back();
}
function goList() {
if (isPopupEmbedded.value) {
emit('back');
return;
}
router.push(hubRoute.value);
}
@@ -124,7 +149,11 @@ async function confirmDelete() {
try {
await deleteMessage(messageId.value);
deleteConfirmVisible.value = false;
router.replace(hubRoute.value);
if (isPopupEmbedded.value) {
emit('back');
} else {
router.replace(hubRoute.value);
}
} finally {
deleting.value = false;
}
@@ -144,8 +173,8 @@ watch(messageId, () => {
</script>
<template>
<div class="message-detail">
<header class="page-header">
<div class="message-detail" :class="{ 'is-embedded': embedded, 'is-hub-embedded': isHubEmbedded, 'is-popup-embedded': isPopupEmbedded }">
<header v-if="!isHubEmbedded" class="page-header">
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack"></button>
<h1>{{ t('messages.detail_title') }}</h1>
<button
@@ -160,13 +189,32 @@ watch(messageId, () => {
</button>
</header>
<header v-else class="page-header page-header--hub">
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack">
{{ t('messages.back') }}
</button>
<h1>{{ t('messages.detail_title') }}</h1>
<button
v-if="message"
type="button"
class="delete-header-btn"
:aria-label="t('messages.delete')"
:disabled="deleting"
@click="onDelete"
>
{{ t('messages.delete') }}
</button>
</header>
<div v-if="loading && !message" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="error || !message" class="empty">
<p>{{ t('messages.not_found') }}</p>
<button type="button" class="link-btn" @click="goList">{{ t('messages.view_all') }}</button>
<button v-if="!isHubEmbedded" type="button" class="link-btn" @click="goList">
{{ isPopupEmbedded ? t('common.back') : t('messages.view_all') }}
</button>
</div>
<article v-else class="detail-article">
@@ -393,4 +441,31 @@ watch(messageId, () => {
cursor: pointer;
display: block;
}
.is-hub-embedded {
padding: 0 4px 24px;
}
.is-hub-embedded .page-header--hub {
padding: 0 0 16px;
}
.is-hub-embedded .page-header--hub h1 {
margin: 0;
flex: 1;
font-size: 16px;
font-weight: 800;
}
.is-popup-embedded {
padding: 12px 14px 16px;
}
.is-popup-embedded .page-header {
padding: 0 0 12px;
}
.is-popup-embedded .page-header h1 {
font-size: 15px;
}
</style>

View File

@@ -149,7 +149,7 @@ onUnmounted(() => {
border: 1px solid rgba(201, 162, 39, 0.22);
font-size: 12px;
font-weight: 600;
color: #c9a227;
color: var(--odds-accent);
}
.teams-grid {

View File

@@ -11,7 +11,6 @@ import { usePlayerProfile } from '../composables/usePlayerProfile';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
import walletBg from '../assets/images/wallet-bg.webp';
const { t, locale } = useI18n();
const router = useRouter();
@@ -123,9 +122,6 @@ const balanceAmountClass = computed(() => {
<template v-else>
<div class="wallet-banner">
<img class="wallet-banner-img" :src="walletBg" alt="" />
<div class="wallet-banner-scrim" aria-hidden="true" />
<div class="card-overlay">
<div class="bank-card-top">
<div class="bank-card-top-left">
@@ -315,49 +311,16 @@ const balanceAmountClass = computed(() => {
aspect-ratio: 1.75 / 1;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.wallet-banner::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
pointer-events: none;
}
.wallet-banner-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
object-position: center center;
pointer-events: none;
user-select: none;
}
.wallet-banner-scrim {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background: linear-gradient(
135deg,
#003D6B 0%,
#005B9F 50%,
#003D6B 100%
);
box-shadow: 0 4px 16px rgba(0, 61, 107, 0.18);
background: linear-gradient(135deg, #003D6B 0%, #005B9F 52%, #002847 100%);
}
.card-overlay {
position: absolute;
inset: 0;
z-index: 3;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
padding: 10px 14px 10px;
line-height: 1.3;
-webkit-font-smoothing: antialiased;

View File

@@ -272,8 +272,8 @@ function goCashbackDetail() {
}
.hero.neg {
border-color: rgba(255, 255, 255, 0.35);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.05));
border-color: rgba(0, 102, 204, 0.25);
background: linear-gradient(135deg, rgba(0, 102, 204, 0.08), rgba(0, 102, 204, 0.03));
}
.hero.zero {
@@ -370,9 +370,9 @@ function goCashbackDetail() {
margin-top: 12px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
background: var(--surface-accent);
color: var(--primary-light);
color: var(--primary);
font-size: 13px;
font-weight: 700;
cursor: pointer;

View File

@@ -1,588 +1,525 @@
<script setup lang="ts">
import { ref, onActivated, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ref, onMounted, onActivated, computed } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../utils/walletTx';
import LocaleFlag from '../components/LocaleFlag.vue';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
const router = useRouter();
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
const { t, locale } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { locales, setLocale, initFromUser } = useAppLocale();
const { profileRaw, refreshProfile } = usePlayerProfile();
const profileLoading = ref(true);
const profileError = ref(false);
const rulesExpanded = ref(false);
const cashbackTotal = ref('0');
type Transaction = {
transactionType: string;
displayType?: string;
summary?: string | null;
summaryKind?: 'opening_bonus' | null;
referenceType?: string | null;
amount: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string;
transactionId: string;
};
const items = ref<Transaction[]>([]);
const loading = ref(true);
const PREVIEW_COUNT = 15;
function txLabel(tx: Transaction): string {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
if (translated !== key) return translated;
}
return tx.transactionType;
}
function txSubtitle(tx: Transaction): string {
return txSummaryLabel(tx, t);
}
function goDetail(tx: Transaction) {
if (!tx.transactionId) return;
router.push(`/wallet/transactions/${tx.transactionId}`);
}
function goCashbacks() {
router.push('/wallet/cashbacks');
}
async function fetchData() {
loading.value = true;
async function fetchCashbackTotal() {
try {
const { data } = await api.get('/player/wallet/transactions', { params: { page: 1 } });
const result = data.data ?? { items: [] };
items.value = (result.items ?? []).slice(0, PREVIEW_COUNT);
const { data } = await api.get('/player/cashbacks');
cashbackTotal.value = sumCashbackAmount(parseCashbackApiData(data.data)).toString();
} catch {
/* ignore */
} finally {
loading.value = false;
try {
const { data } = await api.get('/player/wallet/transactions/stats');
const byType = data.data?.byType ?? [];
let sum = 0;
for (const g of byType) {
if (['CASHBACK', 'CASHBACK_DEPOSIT'].includes(g.transactionType?.toUpperCase())) {
sum += Math.abs(parseFloat(g.totalAmount ?? '0'));
}
}
cashbackTotal.value = sum.toString();
} catch {
// ignore
}
}
}
async function fetchProfile() {
profileLoading.value = true;
profileError.value = false;
try {
await refreshProfile();
initFromUser(profileRaw.value?.locale);
void fetchCashbackTotal();
} catch {
profileError.value = true;
} finally {
profileLoading.value = false;
}
}
async function refreshAll() {
await fetchProfile();
}
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: fetchData,
onRefresh: refreshAll,
});
onMounted(fetchData);
onActivated(fetchData);
onMounted(refreshAll);
onActivated(refreshAll);
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
async function changeLocale(code: string) {
await setLocale(code);
}
function logout() {
auth.logout();
router.push('/');
}
const balanceDisplay = computed(() =>
formatMoney(profileRaw.value?.wallet?.availableBalance, locale.value),
);
const balanceAmountClass = computed(() => {
const len = balanceDisplay.value.length;
if (len <= 10) return 'balance-amount--xl';
if (len <= 13) return 'balance-amount--lg';
if (len <= 16) return 'balance-amount--md';
if (len <= 19) return 'balance-amount--sm';
return 'balance-amount--xs';
});
</script>
<template>
<div class="wallet-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<div class="pull-indicator" :style="pullIndicatorStyle()">
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
<span class="loading-text">{{ t('bet.loading') }}</span>
<div v-if="profileLoading" class="loading-state">
<GoldSpinner :size="36" :active="true" />
</div>
<div v-else-if="profileError" class="error-state">
<p class="error-text">{{ t('common.load_failed') }}</p>
<button type="button" class="retry-btn" @click="fetchProfile">{{ t('common.retry') }}</button>
</div>
<template v-else>
<div class="wallet-links">
<button type="button" class="wallet-link" @click="goCashbacks">
{{ t('wallet.view_cashbacks') }}
</button>
<button
v-if="items.length"
type="button"
class="wallet-link wallet-link--primary"
@click="router.push('/wallet/detail')"
>
{{ t('wallet.view_all') }}
</button>
</div>
<div v-if="items.length" class="tx-list">
<button
v-for="tx in items"
:key="tx.transactionId"
type="button"
class="tx-row"
@click="goDetail(tx)"
>
<div class="tx-col-left">
<span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
<div class="wallet-banner">
<div class="card-overlay">
<div class="bank-card-top">
<div class="bank-card-top-left">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<div class="bank-card-brand-text">
<span class="brand-name">THEBET365</span>
<span class="bank-card-type">MEMBER</span>
</div>
</div>
<RouterLink to="/wallet/recharge" class="recharge-btn">
<span class="recharge-icon">+</span>
<span>{{ t('recharge.title') }}</span>
</RouterLink>
</div>
<div class="tx-col-right">
<div class="bank-card-balance">
<span class="bank-card-label">{{ t('wallet.balance') }}</span>
<p class="bank-card-number balance-amount" :class="balanceAmountClass">{{ balanceDisplay }}</p>
</div>
<span :class="txAmountClass(tx.amount)">
<div class="bank-card-footer">
<div class="bank-card-field">
<span class="bank-card-label">{{ t('wallet.card_holder') }}</span>
<span class="bank-card-holder">{{ profileRaw?.username }}</span>
</div>
<div class="bank-card-field bank-card-field--center">
<span class="bank-card-label">{{ t('cashback.total_received') }}</span>
<span class="bank-card-stat">{{ formatMoney(cashbackTotal, locale) }}</span>
</div>
<div class="bank-card-field bank-card-field--right">
<span class="bank-card-label">{{ t('wallet.unsettled') }}</span>
<span class="bank-card-stat">{{ formatMoney(profileRaw?.wallet?.frozenBalance, locale) }}</span>
</div>
</div>
</div>
</div>
{{ formatMoney(tx.amount, locale) }}
<section class="settings-group">
<RouterLink to="/profile/edit" class="settings-cell">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6" />
</svg>
<span class="cell-label">{{ t('profile.edit') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<div class="rules-cell">
<button
type="button"
class="settings-cell rules-toggle"
:aria-expanded="rulesExpanded"
@click="rulesExpanded = !rulesExpanded"
>
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M7 4h10v16H7z" />
<path d="M10 8h6M10 12h6M10 16h4" />
</svg>
<span class="cell-label">{{ t('profile.rules_title') }}</span>
</span>
<span class="tx-arrow" aria-hidden="true"></span>
<span class="cell-chevron" :class="{ open: rulesExpanded }" aria-hidden="true"></span>
</button>
<div v-show="rulesExpanded" class="rules-body">
<p>{{ t('profile.rules_p1') }}</p>
<p>{{ t('profile.rules_p2') }}</p>
<p>{{ t('profile.rules_p3') }}</p>
<p>{{ t('profile.rules_p4') }}</p>
<p>{{ t('profile.rules_p5') }}</p>
</div>
</div>
</button>
</div>
<div v-if="!items.length" class="empty">
{{ t('wallet.no_records') }}
</div>
<div class="settings-cell settings-cell--stack">
<div class="cell-head">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c2.5 2.8 4 6 4 9s-1.5 6.2-4 9M12 3c-2.5 2.8-4 6-4 9s1.5 6.2 4 9" />
</svg>
<span class="cell-label">{{ t('profile.language') }}</span>
</div>
<div class="lang-segment" role="group" :aria-label="t('profile.language')">
<button
v-for="item in locales"
:key="item.code"
type="button"
class="lang-opt"
:class="{ active: locale === item.code }"
@click="changeLocale(item.code)"
>
<LocaleFlag :locale="item.code" :size="14" />
<span>{{ item.label }}</span>
</button>
</div>
</div>
</section>
<button type="button" class="logout-btn" @click="logout">
{{ t('auth.logout') }}
</button>
</template>
</div>
</template>
<style scoped>
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.wallet-page {
padding-bottom: 24px;
padding: 6px 0 24px;
}
.pull-indicator {
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
gap: 12px;
min-height: 60vh;
}
.state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
color: var(--text-muted);
padding: 56px 20px;
font-weight: 500;
.error-text {
color: #6B7280;
font-size: 14px;
}
.loading-text {
font-size: 13px;
}
.wallet-links {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 0 12px;
}
.wallet-link {
background: none;
border: none;
padding: 4px 0;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
}
.wallet-link--primary {
color: var(--text);
font-weight: 600;
}
.wallet-link:active {
opacity: 0.72;
}
.tx-list {
margin-bottom: 0;
background: var(--gradient-card);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-card-sm);
}
.tx-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
text-align: left;
font-size: 14px;
padding: 14px 16px;
border: none;
border-bottom: 1px solid var(--border);
.retry-btn {
padding: 8px 24px;
border-radius: 6px;
border: 1px solid #003D6B;
background: transparent;
color: #005B9F;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.tx-row:last-child {
border-bottom: none;
}
.tx-row:active {
background: var(--bg-hover);
}
.tx-col-left {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.tx-col-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.tx-summary {
font-size: 12px;
color: var(--text-dim);
font-weight: 500;
white-space: nowrap;
.wallet-banner {
position: relative;
width: 100%;
margin-bottom: 10px;
aspect-ratio: 1.75 / 1;
border-radius: 12px;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 4px 16px rgba(0, 61, 107, 0.18);
background: linear-gradient(135deg, #003D6B 0%, #005B9F 52%, #002847 100%);
}
.tx-type {
font-weight: 600;
color: var(--text);
letter-spacing: -0.01em;
.card-overlay {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
padding: 10px 14px;
line-height: 1.3;
}
.pos {
color: var(--success);
font-weight: 600;
font-size: 14px;
font-variant-numeric: tabular-nums;
.bank-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.neg {
color: var(--text);
font-weight: 600;
font-size: 14px;
font-variant-numeric: tabular-nums;
.bank-card-top-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.zero {
color: var(--text-muted);
font-weight: 600;
font-size: 14px;
font-variant-numeric: tabular-nums;
.brand-logo {
width: 18px;
height: 18px;
object-fit: contain;
}
.bank-card-brand-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.tx-time {
.brand-name {
font-size: 11px;
color: var(--text-dim);
font-weight: 700;
color: #fff;
letter-spacing: 0.14em;
}
.tx-arrow {
font-size: 15px;
color: var(--text-dim);
font-weight: 400;
line-height: 1;
.bank-card-type {
font-size: 8px;
font-weight: 800;
letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.6);
font-style: italic;
}
.empty {
text-align: center;
color: var(--text-muted);
padding: 40px 16px;
font-weight: 500;
.bank-card-label {
display: block;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.75);
}
.recharge-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 5px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
font-size: 11px;
font-weight: 600;
text-decoration: none;
}
.recharge-icon {
font-size: 12px;
font-weight: 700;
}
.bank-card-balance {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 0;
padding: 1px 0;
gap: 3px;
}
.bank-card-number {
margin: 0;
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
font-weight: 700;
color: #fff;
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.balance-amount--xl { font-size: clamp(22px, 5.6vw, 27px); }
.balance-amount--lg { font-size: clamp(19px, 5vw, 24px); }
.balance-amount--md { font-size: clamp(16px, 4.4vw, 20px); }
.balance-amount--sm { font-size: clamp(14px, 3.8vw, 17px); }
.balance-amount--xs { font-size: clamp(12px, 3.2vw, 14px); }
.bank-card-footer {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.bank-card-field {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.bank-card-field--center { text-align: center; }
.bank-card-field--right { text-align: right; }
.bank-card-holder,
.bank-card-stat {
font-size: 12px;
font-weight: 600;
color: #fff;
}
.bank-card-holder {
font-weight: 700;
text-transform: uppercase;
word-break: break-all;
}
.settings-group {
background: #fff;
border: 1px solid #E5E7EB;
border-radius: 8px;
overflow: hidden;
margin-bottom: 10px;
}
.settings-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 0 14px;
background: none;
color: #1A1A2E;
font-size: 13px;
font-weight: 600;
text-decoration: none;
border-bottom: 1px solid #E5E7EB;
}
.settings-cell:last-child {
border-bottom: none;
}
.settings-cell--stack {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 10px 14px 12px;
min-height: auto;
}
.cell-main,
.cell-head {
display: flex;
align-items: center;
gap: 10px;
}
.cell-icon {
width: 18px;
height: 18px;
color: #6B7280;
}
.cell-chevron {
color: #6B7280;
font-size: 20px;
font-weight: 300;
transition: transform 0.15s ease;
}
.cell-chevron.open {
transform: rotate(90deg);
}
.rules-toggle {
border: none;
cursor: pointer;
font-family: inherit;
}
.rules-cell .rules-body {
padding: 0 14px 12px;
border-bottom: 1px solid #E5E7EB;
font-size: 12px;
line-height: 1.55;
color: #6B7280;
}
.rules-body p {
margin: 0 0 8px;
}
.lang-segment {
display: flex;
gap: 6px;
}
.lang-opt {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 30px;
border-radius: 6px;
border: 1px solid #E5E7EB;
background: #fff;
color: #6B7280;
font-size: 12px;
font-weight: 700;
}
.lang-opt.active {
border-color: #003D6B;
color: #003D6B;
background: rgba(0, 61, 107, 0.06);
}
.logout-btn {
width: 100%;
min-height: 40px;
border-radius: 8px;
border: 1px solid #DC2626;
background: #fff;
color: #DC2626;
font-size: 13px;
font-weight: 700;
}
</style>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { useViewport } from '../composables/useViewport';
import MobileProfileView from './MobileProfileView.vue';
import DesktopProfileView from './desktop/DesktopProfileView.vue';
const { isDesktop } = useViewport();
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
onMounted(() => {
void router.replace('/wallet');
});
</script>
<template>
<DesktopProfileView v-if="isDesktop" />
<MobileProfileView v-else />
<div />
</template>

View File

@@ -1,9 +1,17 @@
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
import { useViewport } from '../composables/useViewport';
import MobileWalletView from './MobileWalletView.vue';
import DesktopWalletView from './desktop/DesktopWalletView.vue';
const { isDesktop } = useViewport();
const DesktopWalletView = defineAsyncComponent(
() => import('./desktop/DesktopWalletView.vue'),
);
const MobileWalletView = defineAsyncComponent(
() => import('./MobileWalletView.vue'),
);
</script>
<template>
<DesktopWalletView v-if="isDesktop" />
<MobileWalletView v-else />

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
/**
* Desktop Announcement Detail View
* Reuses the existing AnnouncementDetailView component in a desktop shell wrapper.

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onActivated } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -135,7 +135,7 @@ onActivated(() => {
padding: 18px 20px;
border: 1px solid var(--border);
border-radius: 10px;
background: linear-gradient(135deg, rgba(0, 102, 204, 0.08) 0%, rgba(18, 18, 18, 0.95) 42%);
background: linear-gradient(135deg, rgba(0, 102, 204, 0.08) 0%, var(--bg-card) 42%);
}
.back-btn {
@@ -147,7 +147,7 @@ onActivated(() => {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #141414;
background: var(--bg-body);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
@@ -238,7 +238,7 @@ onActivated(() => {
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: rgba(18, 18, 18, 0.96);
background: var(--bg-card);
text-align: left;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
@@ -246,12 +246,12 @@ onActivated(() => {
.announce-card:hover {
border-color: var(--border-active);
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
box-shadow: var(--shadow-md);
}
.card-cover {
aspect-ratio: 16 / 7;
background: #0a0a0a;
background: var(--bg-card);
overflow: hidden;
}
@@ -299,7 +299,7 @@ onActivated(() => {
font-size: 15px;
font-weight: 800;
line-height: 1.45;
color: #fff;
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -30,6 +30,7 @@ const isDetailRoute = computed(
.desktop-hub-page {
flex: 1;
min-height: 0;
height: 100%;
}
.hub-placeholder {

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter, RouterLink } from 'vue-router';
@@ -180,7 +180,7 @@ function statusLabel(status: string) {
.card-title { font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--desktop-border); }
.sel-row { padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.04); }
.sel-row { padding: 12px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.04); }
.sel-row:last-child { border-bottom: none; }
.sel-match { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
.sel-meta { display: flex; gap: 10px; align-items: center; font-size: 12px; color: var(--text-muted); }

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, ref, onMounted, onActivated, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import Pagination from '../../components/desktop/Pagination.vue';
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
import DesktopWalletPageHeader from '../../components/desktop/DesktopWalletPageHeader.vue';
import { formatMoney, formatMoneyCompact } from '../../utils/localeDisplay';
import { parseCashbackApiData, type CashbackRecord } from '../../utils/cashback';
@@ -81,15 +81,14 @@ onActivated(loadData);
<template>
<div class="desktop-records-page">
<header class="desktop-records-header">
<div class="desktop-records-header-main">
<DesktopWalletSubNav />
</div>
<div v-if="!loading && allItems.length" class="desktop-records-actions summary-chip">
<span class="summary-label">{{ t('cashback.total_received') }}</span>
<span class="summary-value">{{ formatMoney(totalAmount, locale) }}</span>
</div>
</header>
<DesktopWalletPageHeader :show-recharge="false">
<template v-if="!loading && allItems.length" #actions>
<div class="summary-chip">
<span class="summary-label">{{ t('cashback.total_received') }}</span>
<span class="summary-value">{{ formatMoney(totalAmount, locale) }}</span>
</div>
</template>
</DesktopWalletPageHeader>
<section class="desktop-records-panel">
<div class="desktop-records-table-wrap">

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -244,8 +244,8 @@ void loadParlayMatches(true);
}
.main-tab:hover {
color: #fff;
background: rgba(255, 255, 255, 0.04);
color: var(--primary);
background: var(--bg-hover);
}
.main-tab.active {

View File

@@ -1,12 +1,17 @@
<script setup lang="ts">
<script setup lang="ts">
/**
* Desktop Forgot Password View
*/
import { RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import ForgotPasswordView from '../ForgotPasswordView.vue';
const { t } = useI18n();
</script>
<template>
<div class="desktop-auth-shell">
<RouterLink to="/login" class="auth-back-link">{{ t('common.back') }}</RouterLink>
<div class="auth-brand">
<div class="brand-inner">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
@@ -27,6 +32,23 @@ import ForgotPasswordView from '../ForgotPasswordView.vue';
display: flex;
min-height: 100vh;
background: var(--desktop-bg);
position: relative;
}
.auth-back-link {
position: absolute;
top: 20px;
left: 24px;
z-index: 2;
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
text-decoration: none;
transition: color 0.2s;
}
.auth-back-link:hover {
color: var(--text);
}
.auth-brand {
@@ -34,7 +56,7 @@ import ForgotPasswordView from '../ForgotPasswordView.vue';
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-card) 50%, var(--bg-card) 100%);
border-right: 1px solid var(--desktop-border);
position: relative;
overflow: hidden;

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, computed, ref } from 'vue';
<script setup lang="ts">
import { onMounted, onUnmounted, computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import DesktopBanner3DCarousel from '../../components/desktop/DesktopBanner3DCarousel.vue';
@@ -10,30 +10,50 @@ import GoldSpinner from '../../components/GoldSpinner.vue';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import type { PlayerHomeMatch } from '../../composables/usePlayerHome';
type HotTab = 'hot' | 'upcoming';
const { t, locale } = useI18n();
const router = useRouter();
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
const activeTab = ref<HotTab>('hot');
const activeFeaturedIndex = ref(0);
let rotateInterval: number | undefined;
function startFeaturedRotation() {
stopFeaturedRotation();
if (hotMatches.value.length > 1) {
rotateInterval = window.setInterval(() => {
activeFeaturedIndex.value = (activeFeaturedIndex.value + 1) % hotMatches.value.length;
}, 4500);
}
}
function stopFeaturedRotation() {
if (rotateInterval) {
window.clearInterval(rotateInterval);
rotateInterval = undefined;
}
}
watch(() => hotMatches.value.length, () => {
activeFeaturedIndex.value = 0;
startFeaturedRotation();
});
const bannerFallbackTo = computed(() => {
const id = announcementItems.value[0]?.id;
return id ? `/announcements/${id}` : '/announcements';
});
const displayedMatches = computed<PlayerHomeMatch[]>(() =>
activeTab.value === 'hot' ? hotMatches.value : upcomingMatches.value,
);
const displayedMatches = computed<PlayerHomeMatch[]>(() => upcomingMatches.value);
const emptyMessage = computed(() =>
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
);
const emptyMessage = computed(() => t('home.upcoming_empty'));
onMounted(() => {
void load(true);
startFeaturedRotation();
});
onUnmounted(stopFeaturedRotation);
function goMatch(id: string) {
router.push(`/match/${id}`);
}
@@ -41,6 +61,11 @@ function goMatch(id: string) {
function formatKickoff(startTime: string) {
return formatLocalMatchDateTime(startTime, locale.value, { variant: 'full' });
}
function setFeaturedIndex(index: number) {
activeFeaturedIndex.value = index;
startFeaturedRotation();
}
</script>
<template>
@@ -49,31 +74,119 @@ function formatKickoff(startTime: string) {
<DesktopBanner3DCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
</section>
<section class="quick-bento" @mouseenter="stopFeaturedRotation" @mouseleave="startFeaturedRotation">
<div class="bento-item bento-item--large bento-item--featured-carousel">
<div v-if="hotMatches.length" class="bento-header-row fixed-header">
<span class="featured-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="14" height="14" class="tag-icon"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{{ t('home.hot_matches') }}
</span>
<span class="bento-badge">HOT</span>
</div>
<div class="carousel-viewport">
<div
class="carousel-track"
:style="{ transform: `translateX(-${activeFeaturedIndex * 100}%)` }"
>
<template v-if="hotMatches.length">
<div
v-for="match in hotMatches"
:key="match.id"
class="carousel-slide clickable"
@click="goMatch(match.id)"
>
<div class="bento-content--vertical">
<div class="bento-header-row placeholder-header" aria-hidden="true">
<span class="featured-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="14" height="14" class="tag-icon"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{{ t('home.hot_matches') }}
</span>
<span class="bento-badge">HOT</span>
</div>
<div class="featured-vs-area">
<div class="featured-team">
<TeamEmblem
size="md"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="featured-team-name">{{ match.homeTeamName }}</span>
</div>
<span class="featured-vs">VS</span>
<div class="featured-team">
<TeamEmblem
size="md"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="featured-team-name">{{ match.awayTeamName }}</span>
</div>
</div>
<div class="featured-footer">
<span class="featured-time">{{ formatKickoff(match.startTime) }}</span>
<span class="featured-action-btn">{{ t('announcements.go_link') }} </span>
</div>
</div>
</div>
</template>
<div v-else class="carousel-slide clickable" @click="router.push('/bet')">
<div class="bento-content--vertical">
<div class="bento-header-row">
<span class="bento-icon bento-icon--bet">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="24" height="24"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 000 20 14.5 14.5 0 000-20M2 12h20"/></svg>
</span>
<span class="bento-badge">HOT</span>
</div>
<div class="bento-text">
<span class="bento-title bento-title--lg">{{ t('nav.bet') }}</span>
<span class="bento-desc">{{ t('home.bento_desc') }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="hotMatches.length > 1" class="carousel-dots">
<button
v-for="(_, i) in hotMatches"
:key="i"
type="button"
class="carousel-dot"
:class="{ active: i === activeFeaturedIndex }"
:aria-label="`${i + 1}`"
@click="setFeaturedIndex(i)"
/>
</div>
</div>
<button type="button" class="bento-item bento-item--small" @click="router.push('/wallet/recharge')">
<span class="bento-icon bento-icon--recharge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M12 15h4"/></svg>
</span>
<span class="bento-title bento-title--sm">{{ t('recharge.title') }}</span>
</button>
<button type="button" class="bento-item bento-item--small" @click="router.push('/bets')">
<span class="bento-icon bento-icon--history">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M6 4h12a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z" stroke-linejoin="round"/>
<path d="M8 9h8M8 12.5h8M8 16h5" stroke-linecap="round"/>
</svg>
</span>
<span class="bento-title bento-title--sm">{{ t('home.view_bet_history') }}</span>
</button>
</section>
<div class="content-row">
<div class="matches-hub">
<div class="hub-header">
<div class="tabs-list" role="tablist">
<button
type="button"
role="tab"
class="hub-tab"
:class="{ active: activeTab === 'hot' }"
:aria-selected="activeTab === 'hot'"
@click="activeTab = 'hot'"
>
{{ t('home.hot_tab') || '热门赛事' }}
</button>
<button
type="button"
role="tab"
class="hub-tab"
:class="{ active: activeTab === 'upcoming' }"
:aria-selected="activeTab === 'upcoming'"
@click="activeTab = 'upcoming'"
>
{{ t('home.upcoming_tab') || '即将开赛' }}
</button>
</div>
<h2 class="section-title">{{ t('home.upcoming_tab') }}</h2>
<button type="button" class="refresh-btn" :disabled="loading" @click="load(true)">
<span v-if="loading" class="spin"></span>
<span v-else> {{ t('bet.refresh') || '刷新' }}</span>
@@ -152,6 +265,282 @@ function formatKickoff(startTime: string) {
width: 100%;
}
.quick-bento {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(220px, 1fr);
grid-template-rows: 76px 76px;
gap: 12px;
}
.bento-item {
display: flex;
align-items: center;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg-card);
box-shadow: var(--shadow);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
padding: 14px 16px;
text-decoration: none;
box-sizing: border-box;
}
.bento-item:hover {
border-color: var(--border-active);
box-shadow: var(--shadow-md);
}
.bento-item--large {
grid-row: 1 / 3;
height: 100%;
background: linear-gradient(135deg, #F3F8FC 0%, #FFFFFF 100%);
border-color: rgba(0, 61, 107, 0.15);
position: relative;
overflow: hidden;
padding: 0;
}
.carousel-viewport {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
.carousel-track {
display: flex;
height: 100%;
width: 100%;
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
}
.carousel-slide {
flex: 0 0 100%;
min-width: 100%;
height: 100%;
padding: 14px 16px 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.carousel-dots {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 6px;
z-index: 10;
}
.carousel-dot {
width: 6px;
height: 6px;
border-radius: 50%;
border: none;
padding: 0;
background: rgba(0, 61, 107, 0.15);
cursor: pointer;
transition: background 0.25s, width 0.25s;
}
.carousel-dot.active {
width: 18px;
border-radius: 3px;
background: var(--primary);
}
.featured-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 700;
color: var(--primary);
background: rgba(0, 61, 107, 0.06);
padding: 4px 8px;
border-radius: 6px;
line-height: 1;
}
.tag-icon {
color: var(--odds-accent, #F97316);
}
.featured-vs-area {
display: flex;
align-items: center;
justify-content: space-around;
width: 100%;
margin: 10px 0;
gap: 12px;
}
.featured-team {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.featured-team-name {
font-size: 13px;
font-weight: 800;
color: var(--text);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.featured-vs {
font-size: 16px;
font-weight: 800;
color: var(--odds-accent, #F97316);
font-style: italic;
flex-shrink: 0;
}
.featured-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border-top: 1px dashed rgba(0, 61, 107, 0.12);
padding-top: 10px;
margin-top: 4px;
}
.featured-time {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.featured-action-btn {
font-size: 12px;
font-weight: 800;
color: var(--primary);
background: rgba(0, 61, 107, 0.06);
padding: 4px 10px;
border-radius: 6px;
transition: background 0.15s;
}
.carousel-slide:hover .featured-action-btn {
background: rgba(0, 61, 107, 0.12);
}
.bento-content--vertical {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
width: 100%;
}
.bento-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
width: 100%;
}
.fixed-header {
position: absolute;
top: 14px;
left: 16px;
right: 16px;
width: auto;
z-index: 20;
pointer-events: none;
}
.placeholder-header {
visibility: hidden;
pointer-events: none;
}
.bento-badge {
font-size: 10px;
font-weight: 800;
color: #fff;
background: var(--odds-accent, #F97316);
padding: 3px 7px;
border-radius: 4px;
letter-spacing: 0.05em;
line-height: 1;
}
.bento-item--small {
flex-direction: row;
gap: 12px;
justify-content: flex-start;
height: 100%;
}
.bento-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bento-icon--bet {
background: rgba(0, 61, 107, 0.08);
color: var(--primary);
}
.bento-icon--recharge {
background: rgba(5, 150, 105, 0.08);
color: var(--success);
}
.bento-icon--history {
background: rgba(248, 151, 31, 0.08);
color: var(--odds-accent, #E8870A);
}
.bento-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.bento-title {
font-size: 15px;
font-weight: 800;
color: var(--text);
text-align: left;
}
.bento-title--sm {
font-size: 14px;
font-weight: 700;
}
.bento-title--lg {
font-size: 18px;
font-weight: 800;
}
.bento-desc {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
.content-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
@@ -168,7 +557,7 @@ function formatKickoff(startTime: string) {
}
.matches-hub {
background: rgba(20, 20, 20, 0.85);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
@@ -183,28 +572,14 @@ function formatKickoff(startTime: string) {
padding-bottom: 10px;
}
.tabs-list {
display: flex;
gap: 16px;
}
.hub-tab {
background: transparent;
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
padding: 8px 4px;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.hub-tab:hover {
color: #fff;
}
.hub-tab.active {
color: var(--primary-light);
border-bottom-color: var(--primary);
.section-title {
margin: 0;
font-size: 15px;
font-weight: 800;
color: var(--primary);
padding-left: 10px;
border-left: 3px solid var(--primary);
line-height: 1.3;
}
.refresh-btn {
@@ -242,7 +617,7 @@ function formatKickoff(startTime: string) {
}
.match-card {
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
@@ -290,7 +665,7 @@ function formatKickoff(startTime: string) {
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.team-side {
@@ -304,7 +679,7 @@ function formatKickoff(startTime: string) {
.team-name {
font-size: 12px;
font-weight: 700;
color: #eee;
color: var(--text);
text-align: center;
max-width: 100px;
overflow: hidden;
@@ -316,7 +691,7 @@ function formatKickoff(startTime: string) {
font-size: 11px;
font-weight: 800;
color: var(--text-muted);
background: #222;
background: var(--bg-body);
width: 22px;
height: 22px;
display: flex;

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -181,7 +181,7 @@ onActivated(refreshUnreadCount);
position: relative;
}
.hub-tab:hover { color: #fff; }
.hub-tab:hover { color: var(--primary); }
.hub-tab.active {
color: var(--primary-light);
@@ -194,7 +194,7 @@ onActivated(refreshUnreadCount);
width: 6px;
height: 6px;
border-radius: 50%;
background: #333;
background: var(--bg-hover);
flex-shrink: 0;
transition: all 0.2s;
}

View File

@@ -1,13 +1,18 @@
<script setup lang="ts">
<script setup lang="ts">
/**
* Desktop Login View
* Reuses the existing LoginView form in a centered two-column auth layout.
*/
import { RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import LoginView from '../LoginView.vue';
const { t } = useI18n();
</script>
<template>
<div class="desktop-auth-shell">
<RouterLink to="/" class="auth-back-link">{{ t('common.back') }}</RouterLink>
<div class="auth-brand">
<div class="brand-inner">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
@@ -42,6 +47,23 @@ import LoginView from '../LoginView.vue';
display: flex;
min-height: 100vh;
background: var(--desktop-bg);
position: relative;
}
.auth-back-link {
position: absolute;
top: 20px;
left: 24px;
z-index: 2;
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
text-decoration: none;
transition: color 0.2s;
}
.auth-back-link:hover {
color: var(--text);
}
.auth-brand {
@@ -49,7 +71,7 @@ import LoginView from '../LoginView.vue';
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-card) 50%, var(--bg-card) 100%);
border-right: 1px solid var(--desktop-border);
position: relative;
overflow: hidden;

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -456,7 +456,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
padding: 10px 16px;
font-size: 12px;
color: var(--text-muted);
background: #0c0c0c;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -503,7 +503,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
}
.match-score-band {
background: linear-gradient(180deg, #181818 0%, #111 100%);
background: linear-gradient(180deg, var(--bg-body) 0%, var(--bg-card) 100%);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px 16px;
@@ -527,7 +527,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
.team-side .name {
font-size: 13px;
font-weight: 800;
color: #fff;
color: var(--text);
}
.score-arena {
@@ -568,7 +568,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
}
.result-stats-card {
background: rgba(20, 20, 20, 0.8);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
@@ -589,7 +589,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
}
.stat-pill {
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 12px;
@@ -599,7 +599,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
}
.stat-pill .lbl { color: var(--text-muted); }
.stat-pill .val { color: #fff; font-weight: 700; }
.stat-pill .val { color: var(--text); font-weight: 700; }
.market-filter-bar {
display: flex;
@@ -608,7 +608,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(18, 18, 18, 0.85);
background: var(--bg-card);
min-width: 0;
}
@@ -642,7 +642,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
padding: 3px 7px;
border-radius: 4px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.03);
background: rgba(0, 0, 0, 0.04);
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
@@ -652,8 +652,8 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
}
.market-filter-chip:hover {
color: #fff;
background: rgba(255, 255, 255, 0.06);
color: var(--primary);
background: var(--bg-hover);
}
.market-filter-chip.active {
@@ -700,7 +700,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: rgba(18, 18, 18, 0.98);
background: var(--bg-card);
align-self: start;
}
@@ -714,14 +714,14 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #141414;
background: var(--bg-body);
border-bottom: 1px solid var(--border);
}
.market-title {
font-size: 11px;
font-weight: 800;
color: #eee;
color: var(--text);
line-height: 1.25;
}
@@ -741,7 +741,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: rgba(18, 18, 18, 0.98);
background: var(--bg-card);
align-self: start;
}
@@ -755,11 +755,11 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #141414;
background: var(--bg-body);
border-bottom: 1px solid var(--border);
font-size: 11px;
font-weight: 800;
color: #eee;
color: var(--text);
}
.locked-lbl {

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onActivated, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -80,6 +80,7 @@ onActivated(refreshUnreadCount);
.desktop-hub-page {
flex: 1;
min-height: 0;
height: 100%;
}
.hub-aside {
@@ -123,7 +124,7 @@ onActivated(refreshUnreadCount);
}
.hub-tab:hover {
color: #fff;
color: var(--primary);
}
.hub-tab.active {
@@ -140,7 +141,7 @@ onActivated(refreshUnreadCount);
width: 6px;
height: 6px;
border-radius: 50%;
background: #333;
background: var(--bg-hover);
flex-shrink: 0;
}

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { useI18n } from 'vue-i18n';
import { RouterLink } from 'vue-router';

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -182,7 +182,7 @@ onUnmounted(stopPolling);
padding: 10px 16px;
font-size: 12px;
color: var(--text-muted);
background: #0c0c0c;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -237,7 +237,7 @@ onUnmounted(stopPolling);
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(18, 18, 18, 0.98);
background: var(--bg-card);
overflow: hidden;
}
@@ -274,8 +274,8 @@ onUnmounted(stopPolling);
.settled-tag {
font-size: 10px;
font-weight: 800;
color: #c9a227;
border: 1px solid rgba(201, 162, 39, 0.45);
color: var(--odds-accent);
border: 1px solid rgba(248, 151, 31, 0.45);
border-radius: 999px;
padding: 1px 7px;
}
@@ -291,14 +291,14 @@ onUnmounted(stopPolling);
margin: 4px 0 0;
font-size: 11px;
font-weight: 600;
color: #666;
color: var(--text-muted);
}
.hero-hint {
margin: 8px 0 0;
font-size: 11px;
font-weight: 600;
color: #c9a227;
color: var(--odds-accent);
line-height: 1.45;
}

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -8,6 +8,7 @@ import PlayerAvatarModal from '../../components/PlayerAvatarModal.vue';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { usePlayerProfile } from '../../composables/usePlayerProfile';
import { useAuthStore } from '../../stores/auth';
import PageBackButton from '../../components/PageBackButton.vue';
const { t } = useI18n();
const router = useRouter();
@@ -110,6 +111,7 @@ async function onAvatarSelect(key: string | null) {
<main class="account-main">
<div class="edit-layout">
<div class="page-header">
<PageBackButton fallback="/wallet" />
<h1 class="page-title">{{ t('profile.edit_title') }}</h1>
</div>
@@ -216,7 +218,7 @@ async function onAvatarSelect(key: string | null) {
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
.account-main { flex: 1; min-width: 0; padding: 24px 28px; overflow-y: auto; }
.page-header { margin-bottom: 24px; }
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.page-title { font-size: 18px; font-weight: 800; color: var(--primary-light); margin: 0; }
.edit-layout {
@@ -230,7 +232,7 @@ async function onAvatarSelect(key: string | null) {
.edit-card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 10px;
padding: 20px;
}
@@ -267,7 +269,7 @@ async function onAvatarSelect(key: string | null) {
width: 64px;
height: 64px;
border-radius: 50%;
background: #1a1a1a;
background: var(--bg-body);
display: flex;
align-items: center;
justify-content: center;
@@ -305,7 +307,7 @@ async function onAvatarSelect(key: string | null) {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--desktop-border);
background: #111;
background: var(--bg-card);
color: var(--text);
font-size: 14px;
font-weight: 600;
@@ -329,7 +331,7 @@ async function onAvatarSelect(key: string | null) {
padding: 9px 24px;
border-radius: 6px;
background: var(--primary);
color: #111;
color: var(--bg-card);
font-size: 13px;
font-weight: 800;
border: none;

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onMounted, onActivated, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
@@ -11,7 +11,6 @@ import { usePlayerProfile } from '../../composables/usePlayerProfile';
import { useInboxFeature } from '../../composables/useInboxFeature';
import { formatMoney } from '../../utils/localeDisplay';
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
import walletBg from '../../assets/images/wallet-bg.webp';
const { t, locale } = useI18n();
const router = useRouter();
@@ -91,9 +90,6 @@ const balanceDisplay = computed(() =>
<template v-else>
<!-- Wallet Banner -->
<div class="wallet-banner">
<img class="wallet-banner-img" :src="walletBg" alt="" />
<div class="wallet-banner-scrim" aria-hidden="true" />
<div class="card-overlay">
<div class="bank-card-top">
<div class="bank-card-top-left">
@@ -199,46 +195,16 @@ const balanceDisplay = computed(() =>
aspect-ratio: 21 / 9;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.wallet-banner::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
box-shadow: inset 0 0 14px rgba(0, 0, 0, 0.22);
pointer-events: none;
}
.wallet-banner-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
transform: scale(1.05);
filter: brightness(0.86);
pointer-events: none;
user-select: none;
}
.wallet-banner-scrim {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.18) 42%, rgba(0,0,0,0.46) 100%);
box-shadow: 0 4px 16px rgba(0, 61, 107, 0.18);
background: linear-gradient(135deg, #003D6B 0%, #005B9F 52%, #002847 100%);
}
.card-overlay {
position: absolute;
inset: 0;
z-index: 3;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
padding: 24px;
}
@@ -276,7 +242,7 @@ const balanceDisplay = computed(() =>
.bank-card-type {
font-size: 10px;
font-weight: 800;
color: rgba(0, 102, 204, 0.8);
color: rgba(255, 255, 255, 0.72);
font-style: italic;
}
@@ -286,17 +252,16 @@ const balanceDisplay = computed(() =>
gap: 4px;
padding: 8px 16px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(0, 102, 204, 0.35);
color: var(--primary-light);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.28);
color: #fff;
font-size: 13px;
font-weight: 600;
text-decoration: none;
backdrop-filter: blur(6px);
transition: background 0.15s ease;
}
.recharge-btn:hover { background: rgba(0, 102, 204, 0.15); }
.recharge-btn:hover { background: rgba(255, 255, 255, 0.2); }
.bank-card-balance {
flex: 1;
@@ -310,16 +275,15 @@ const balanceDisplay = computed(() =>
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.82);
color: rgba(255, 255, 255, 0.72);
}
.bank-card-number {
margin: 0;
font-family: 'SF Mono', 'Consolas', monospace;
font-weight: 700;
color: var(--primary-light);
color: #fff;
font-size: 38px;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
}
.bank-card-footer {
@@ -355,7 +319,7 @@ const balanceDisplay = computed(() =>
.info-card, .lang-card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 10px;
padding: 20px;
}
@@ -376,7 +340,7 @@ const balanceDisplay = computed(() =>
gap: 12px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-label { width: 80px; flex-shrink: 0; font-size: 12px; color: var(--text-muted); font-weight: 600; }

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { useI18n } from 'vue-i18n';
import { RouterLink } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import Pagination from '../../components/desktop/Pagination.vue';
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
import DesktopWalletPageHeader from '../../components/desktop/DesktopWalletPageHeader.vue';
import { formatMoney } from '../../utils/localeDisplay';
const COL_COUNT = 5;
@@ -73,16 +73,7 @@ function statusLabel(status: string) {
<template>
<div class="desktop-records-page">
<header class="desktop-records-header">
<div class="desktop-records-header-main">
<DesktopWalletSubNav />
</div>
<div class="desktop-records-actions">
<RouterLink to="/wallet/recharge" class="desktop-records-action-btn">
{{ t('wallet.recharge_btn') }}
</RouterLink>
</div>
</header>
<DesktopWalletPageHeader />
<section class="desktop-records-panel">
<div class="desktop-records-table-wrap">

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onMounted, onActivated, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
@@ -6,6 +6,7 @@ import imageCompression from 'browser-image-compression';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { useDepositNotifications } from '../../composables/useDepositNotifications';
import PageBackButton from '../../components/PageBackButton.vue';
const { t } = useI18n();
const router = useRouter();
@@ -128,6 +129,7 @@ onActivated(fetchMethods);
<main class="account-main">
<div class="recharge-layout">
<div class="page-header">
<PageBackButton fallback="/wallet" />
<h1 class="page-title">{{ t('recharge.title') }}</h1>
</div>
@@ -268,6 +270,9 @@ onActivated(fetchMethods);
}
.page-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
@@ -319,7 +324,7 @@ onActivated(fetchMethods);
padding: 9px 24px;
border-radius: 6px;
background: var(--primary);
color: #111;
color: var(--bg-card);
font-size: 13px;
font-weight: 800;
border: none;
@@ -398,7 +403,7 @@ onActivated(fetchMethods);
transition: all 0.2s;
}
.method-card:hover { border-color: rgba(255, 255, 255, 0.15); }
.method-card:hover { border-color: rgba(0, 0, 0, 0.08); }
.method-card.selected {
border-color: var(--border-active);
@@ -425,7 +430,7 @@ onActivated(fetchMethods);
.bank-detail-card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 8px;
padding: 16px 18px;
display: flex;
@@ -492,7 +497,7 @@ onActivated(fetchMethods);
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--desktop-border);
background: #111;
background: var(--bg-card);
color: var(--text);
font-size: 14px;
font-weight: 700;
@@ -524,9 +529,9 @@ onActivated(fetchMethods);
width: 22px;
height: 22px;
border-radius: 50%;
background: #333;
background: var(--bg-hover);
border: none;
color: #fff;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
display: flex;
@@ -566,7 +571,7 @@ onActivated(fetchMethods);
padding: 11px 32px;
border-radius: 6px;
background: var(--primary);
color: #111;
color: var(--bg-card);
font-size: 14px;
font-weight: 800;
border: none;

View File

@@ -1,13 +1,18 @@
<script setup lang="ts">
<script setup lang="ts">
/**
* Desktop Register View
* Reuses the existing RegisterView form in a centered desktop auth layout.
*/
import { RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import RegisterView from '../RegisterView.vue';
const { t } = useI18n();
</script>
<template>
<div class="desktop-auth-shell">
<RouterLink to="/" class="auth-back-link">{{ t('common.back') }}</RouterLink>
<div class="auth-brand">
<div class="brand-inner">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
@@ -28,6 +33,23 @@ import RegisterView from '../RegisterView.vue';
display: flex;
min-height: 100vh;
background: var(--desktop-bg);
position: relative;
}
.auth-back-link {
position: absolute;
top: 20px;
left: 24px;
z-index: 2;
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
text-decoration: none;
transition: color 0.2s;
}
.auth-back-link:hover {
color: var(--text);
}
.auth-brand {
@@ -35,7 +57,7 @@ import RegisterView from '../RegisterView.vue';
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-card) 50%, var(--bg-card) 100%);
border-right: 1px solid var(--desktop-border);
position: relative;
overflow: hidden;

View File

@@ -1,28 +1,21 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import Pagination from '../../components/desktop/Pagination.vue';
import DesktopWalletPageHeader from '../../components/desktop/DesktopWalletPageHeader.vue';
import { formatMoney } from '../../utils/localeDisplay';
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
const { t, locale } = useI18n();
const router = useRouter();
function goBack() {
if (window.history.state && window.history.state.back) {
router.back();
} else {
router.push('/wallet');
}
}
type Transaction = {
transactionType: string;
displayType?: string;
summary?: string | null;
referenceType?: string | null;
amount: string;
createdAt: string;
transactionId: string;
@@ -31,9 +24,10 @@ type Transaction = {
const items = ref<Transaction[]>([]);
const loading = ref(true);
const page = ref(1);
const hasMore = ref(true);
const pageSize = ref(20);
const total = ref(0);
function txLabel(tx: Transaction): string {
function txLabel(tx: Transaction) {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
@@ -42,152 +36,92 @@ function txLabel(tx: Transaction): string {
return tx.transactionType;
}
async function loadPage(p: number) {
function amountClass(amount: string) {
return `desktop-records-amount ${txAmountClass(amount)}`;
}
async function fetchData(p = 1) {
loading.value = true;
try {
const { data } = await api.get('/player/wallet/transactions', { params: { page: p } });
const result = data.data ?? { items: [], total: 0 };
if (p === 1) {
items.value = result.items ?? [];
} else {
items.value = [...items.value, ...(result.items ?? [])];
}
hasMore.value = items.value.length < (result.total ?? 0);
const { data } = await api.get('/player/wallet/transactions', {
params: { page: p, pageSize: pageSize.value },
});
const result = data.data ?? { items: [], total: 0, pageSize: pageSize.value };
total.value = result.total ?? 0;
items.value = result.items ?? [];
page.value = p;
} catch {
if (p === 1) items.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
onMounted(() => loadPage(1));
onActivated(() => loadPage(1));
onMounted(() => fetchData(1));
onActivated(() => fetchData(page.value));
</script>
<template>
<div class="desktop-account-page">
<main class="account-main">
<div class="records-layout">
<div class="page-header">
<button type="button" class="back-btn" @click="router.back()"> {{ t('common.back') }}</button>
<h1 class="page-title">{{ t('wallet.all_records') }}</h1>
</div>
<div class="desktop-records-page">
<DesktopWalletPageHeader />
<div v-if="loading && items.length === 0" class="loading-state">
<GoldSpinner :size="36" />
</div>
<template v-else>
<div v-if="items.length" class="table-container">
<div class="table-wrap">
<table class="dt">
<thead>
<tr>
<th>{{ t('wallet.type') || '类型' }}</th>
<th>{{ t('wallet.note') || '备注' }}</th>
<th class="num-th">{{ t('wallet.amount') || '金额' }}</th>
<th>{{ t('wallet.time') || '时间' }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="tx in items"
:key="tx.transactionId"
class="hover-row"
style="cursor:pointer"
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
>
<td class="type-cell">{{ txLabel(tx) }}</td>
<td class="note-cell">{{ txSummaryLabel(tx, t) || '-' }}</td>
<td :class="['num-cell', txAmountClass(tx.amount)]">{{ formatMoney(tx.amount, locale) }}</td>
<td class="date-cell">{{ new Date(tx.createdAt).toLocaleString() }}</td>
<td class="arrow-cell"></td>
</tr>
</tbody>
</table>
<div v-if="hasMore" class="load-more">
<button type="button" class="load-more-btn" :disabled="loading" @click="loadPage(page + 1)">
<GoldSpinner v-if="loading" :size="18" />
<span v-else>{{ t('common.load_more') || '加载更多' }}</span>
</button>
</div>
<p v-else class="end-hint">{{ t('common.no_more') }}</p>
</div>
</div>
<div v-else class="empty-state">{{ t('wallet.no_records') }}</div>
</template>
<section v-if="loading && !items.length" class="desktop-records-panel">
<div class="desktop-records-loading">
<GoldSpinner :size="36" />
</div>
</main>
</section>
<section v-else-if="items.length" class="desktop-records-panel">
<div class="desktop-records-table-wrap">
<table class="desktop-records-table">
<thead>
<tr>
<th>{{ t('wallet.type') }}</th>
<th>{{ t('wallet.note') }}</th>
<th class="num-th">{{ t('wallet.amount') }}</th>
<th>{{ t('wallet.time') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="tx in items"
:key="tx.transactionId"
class="hover-row"
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
>
<td class="type-cell">{{ txLabel(tx) }}</td>
<td class="muted-cell">{{ txSummaryLabel(tx, t) || '-' }}</td>
<td :class="['num-cell', amountClass(tx.amount)]">{{ formatMoney(tx.amount, locale) }}</td>
<td class="date-cell">{{ new Date(tx.createdAt).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div class="desktop-records-pagination">
<Pagination
v-if="total > 0"
v-model="page"
v-model:pageSize="pageSize"
:total="total"
@change="fetchData"
/>
</div>
</section>
<section v-else class="desktop-records-panel">
<div class="desktop-records-empty">{{ t('wallet.no_records') }}</div>
</section>
</div>
</template>
<style scoped>
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
.account-main { flex: 1; min-width: 0; padding: 24px 28px; display: flex; flex-direction: column; overflow: hidden; }
.records-layout {
display: flex;
flex-direction: column;
max-width: 800px;
margin: 0 auto;
width: 100%;
flex: 0 1 auto;
min-height: 0;
}
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.back-btn { background: none; border: none; color: var(--text-muted); font-size: 14px; font-weight: 700; cursor: pointer; padding: 0; }
.back-btn:hover { color: var(--text); }
.page-title { font-size: 18px; font-weight: 800; color: var(--primary-light); margin: 0; }
.loading-state { display: flex; justify-content: center; align-items: center; padding: 80px 0; }
.table-container {
display: flex;
flex-direction: column;
flex: 0 1 auto;
min-height: 0;
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
overflow: hidden;
}
.table-wrap { flex: 1; overflow-x: auto; overflow-y: auto; }
.dt { width: 100%; border-collapse: collapse; font-size: 13px; }
.dt th {
position: sticky;
top: 0;
z-index: 10;
padding: 9px 12px;
text-align: left;
font-size: 11px;
.type-cell {
font-weight: 700;
color: var(--text-muted);
background: #141414;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--desktop-border);
white-space: nowrap;
}
.dt th:first-child { border-top-left-radius: 8px; }
.dt th:last-child { border-top-right-radius: 8px; }
.num-th { text-align: right; }
.dt td { padding: 11px 12px; border-bottom: 1px solid rgba(255,255,255,0.04); color: var(--text); vertical-align: middle; }
.hover-row { transition: background 0.15s; }
.hover-row:hover { background: rgba(255,255,255,0.03); }
.type-cell { font-weight: 700; }
.note-cell { color: var(--text-muted); font-size: 12px; max-width: 220px; }
.num-cell { text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; }
.date-cell { white-space: nowrap; color: var(--text-muted); font-size: 12px; }
.arrow-cell { color: #555; font-size: 18px; font-weight: 300; text-align: right; width: 24px; }
.pos { color: var(--primary-light); }
.neg { color: var(--danger); }
.zero { color: var(--text-muted); }
.load-more { display: flex; justify-content: center; padding: 20px 0 8px; }
.load-more-btn { display: inline-flex; align-items: center; gap: 8px; padding: 8px 28px; border-radius: 6px; border: 1px solid var(--desktop-border); background: transparent; color: var(--text-muted); font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.2s; }
.load-more-btn:hover:not(:disabled) { border-color: var(--border-active); color: var(--primary-light); }
.load-more-btn:disabled { opacity: 0.5; cursor: default; }
.end-hint { text-align: center; font-size: 12px; color: #444; padding: 16px 0 4px; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 80px 20px; color: var(--text-muted); font-size: 14px; font-weight: 600; }
.muted-cell {
color: var(--text-muted);
max-width: 420px;
}
</style>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
@@ -188,7 +188,7 @@ function txLabel(tx: TxDetail): string {
display: flex;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.detail-row:last-child { border-bottom: none; }

View File

@@ -1,155 +1,415 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
<script setup lang="ts">
import { ref, onMounted, onActivated, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, RouterLink } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import Pagination from '../../components/desktop/Pagination.vue';
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
import DesktopWalletPageHeader from '../../components/desktop/DesktopWalletPageHeader.vue';
import LocaleFlag from '../../components/LocaleFlag.vue';
import { useAuthStore } from '../../stores/auth';
import { useAppLocale } from '../../composables/useAppLocale';
import { usePlayerProfile } from '../../composables/usePlayerProfile';
import { formatMoney } from '../../utils/localeDisplay';
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
import api from '../../api';
const { t, locale } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { locales, setLocale, initFromUser } = useAppLocale();
const { profileRaw, refreshProfile } = usePlayerProfile();
type Transaction = {
transactionType: string;
displayType?: string;
summary?: string | null;
summaryKind?: 'opening_bonus' | null;
referenceType?: string | null;
amount: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string;
transactionId: string;
};
const items = ref<Transaction[]>([]);
const cashbackTotal = ref('0');
const loading = ref(true);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
function txLabel(tx: Transaction): string {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
if (translated !== key) return translated;
async function fetchCashbackTotal() {
try {
const { data } = await api.get('/player/cashbacks');
cashbackTotal.value = sumCashbackAmount(parseCashbackApiData(data.data)).toString();
} catch {
try {
const { data } = await api.get('/player/wallet/transactions/stats');
const byType = data.data?.byType ?? [];
let sum = 0;
for (const g of byType) {
if (['CASHBACK', 'CASHBACK_DEPOSIT'].includes(g.transactionType?.toUpperCase())) {
sum += Math.abs(parseFloat(g.totalAmount ?? '0'));
}
}
cashbackTotal.value = sum.toString();
} catch {
// ignore
}
}
return tx.transactionType;
}
function txSubtitle(tx: Transaction): string {
return txSummaryLabel(tx, t);
}
function amountClass(amount: string) {
return `desktop-records-amount ${txAmountClass(amount)}`;
}
async function fetchData(p = 1) {
async function loadPage() {
loading.value = true;
try {
const [txRes, cbRes] = await Promise.all([
api.get('/player/wallet/transactions', { params: { page: p, pageSize: pageSize.value } }),
p === 1 ? api.get('/player/cashbacks').catch(() => ({ data: { data: [] } })) : Promise.resolve(null),
]);
const result = txRes.data.data ?? { items: [], total: 0, pageSize: pageSize.value };
total.value = result.total ?? 0;
if (p === 1) {
items.value = result.items ?? [];
const cbRows = parseCashbackApiData(cbRes?.data?.data);
cashbackTotal.value = sumCashbackAmount(cbRows).toString();
} else {
items.value = result.items ?? [];
}
page.value = p;
} catch {
/* ignore */
await refreshProfile();
initFromUser(profileRaw.value?.locale);
await fetchCashbackTotal();
} finally {
loading.value = false;
}
}
onMounted(() => fetchData(1));
onActivated(() => fetchData(1));
function logout() {
auth.logout();
router.push('/');
}
const balanceDisplay = computed(() =>
formatMoney(profileRaw.value?.wallet?.availableBalance, locale.value),
);
onMounted(loadPage);
onActivated(loadPage);
</script>
<template>
<div class="desktop-records-page">
<header class="desktop-records-header">
<div class="desktop-records-header-main">
<DesktopWalletSubNav />
</div>
<div class="desktop-records-actions">
<RouterLink to="/wallet/recharge" class="desktop-records-action-btn">
{{ t('wallet.recharge_btn') }}
</RouterLink>
</div>
</header>
<div class="desktop-records-page wallet-overview-page">
<DesktopWalletPageHeader />
<section v-if="loading && !items.length" class="desktop-records-panel">
<div class="desktop-records-loading">
<GoldSpinner :size="36" />
</div>
</section>
<div v-if="loading" class="wallet-loading">
<GoldSpinner :size="36" />
</div>
<section v-else-if="items.length" class="desktop-records-panel">
<div class="desktop-records-table-wrap">
<table class="desktop-records-table">
<thead>
<tr>
<th>{{ t('wallet.type') || '类型' }}</th>
<th>{{ t('wallet.note') || '备注' }}</th>
<th class="num-th">{{ t('wallet.amount') || '金额' }}</th>
<th>{{ t('wallet.time') || '时间' }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="tx in items"
:key="tx.transactionId"
class="hover-row"
style="cursor: pointer"
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
>
<td class="type-cell">{{ txLabel(tx) }}</td>
<td class="muted-cell">{{ txSubtitle(tx) || '-' }}</td>
<td :class="['num-cell', amountClass(tx.amount)]">{{ formatMoney(tx.amount, locale) }}</td>
<td class="date-cell">{{ new Date(tx.createdAt).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div class="desktop-records-pagination">
<Pagination
v-if="total > 0"
v-model="page"
v-model:pageSize="pageSize"
:total="total"
@change="fetchData"
/>
</div>
</section>
<template v-else>
<div class="wallet-overview-content">
<section class="wallet-hero">
<div class="wallet-hero-top">
<div class="wallet-hero-brand">
<img src="/logo.png" alt="" class="wallet-hero-logo" />
<span class="wallet-hero-brand-text">THEBET365</span>
</div>
<RouterLink to="/wallet/recharge" class="wallet-hero-recharge">
+ {{ t('recharge.title') }}
</RouterLink>
</div>
<div class="wallet-hero-body">
<span class="wallet-hero-label">{{ t('wallet.my_balance') }}</span>
<p class="wallet-hero-balance">{{ balanceDisplay }}</p>
</div>
</section>
<section v-else class="desktop-records-panel">
<div class="desktop-records-empty">
<span>{{ t('wallet.no_records') }}</span>
<div class="wallet-stat-grid">
<div class="wallet-stat-card">
<span class="wallet-stat-label">{{ t('profile.username') }}</span>
<span class="wallet-stat-value">{{ profileRaw?.username || '-' }}</span>
</div>
<div class="wallet-stat-card">
<span class="wallet-stat-label">{{ t('wallet.cashback_total') }}</span>
<span class="wallet-stat-value">{{ formatMoney(cashbackTotal, locale) }}</span>
</div>
<div class="wallet-stat-card">
<span class="wallet-stat-label">{{ t('wallet.frozen') }}</span>
<span class="wallet-stat-value">{{ formatMoney(profileRaw?.wallet?.frozenBalance, locale) }}</span>
</div>
</div>
</section>
<section class="wallet-panel">
<div class="wallet-panel-row">
<div class="wallet-panel-label">{{ t('profile.account') }}</div>
<div class="wallet-panel-content">
<div class="wallet-info-line">
<span class="wallet-info-key">{{ t('profile.username') }}</span>
<span class="wallet-info-val">{{ profileRaw?.username }}</span>
</div>
<div class="wallet-info-line">
<span class="wallet-info-key">{{ t('profile.phone') }}</span>
<span class="wallet-info-val">{{ profileRaw?.preferences?.phone || '-' }}</span>
</div>
<RouterLink to="/profile/edit" class="wallet-panel-link">
{{ t('profile.edit') }}
<span aria-hidden="true"></span>
</RouterLink>
</div>
</div>
<div class="wallet-panel-row">
<div class="wallet-panel-label">{{ t('profile.language') }}</div>
<div class="wallet-panel-content">
<div class="wallet-lang-group" role="group" :aria-label="t('profile.language')">
<button
v-for="item in locales"
:key="item.code"
type="button"
class="wallet-lang-btn"
:class="{ active: locale === item.code }"
@click="setLocale(item.code)"
>
<LocaleFlag :locale="item.code" :size="14" />
<span>{{ item.label }}</span>
</button>
</div>
</div>
</div>
<div class="wallet-panel-row wallet-panel-row--actions">
<button type="button" class="wallet-logout-btn" @click="logout">
{{ t('auth.logout') }}
</button>
</div>
</section>
</div>
</template>
</div>
</template>
<style scoped>
.type-cell {
font-weight: 700;
.wallet-overview-page {
gap: 14px;
flex: 0 1 auto !important;
height: auto !important;
overflow: visible;
}
.muted-cell {
.wallet-overview-content {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
max-width: 640px;
margin: 0 auto;
}
.wallet-loading {
display: flex;
justify-content: center;
padding: 48px 0;
}
.wallet-hero {
padding: 20px 22px;
border-radius: 14px;
background: linear-gradient(135deg, #003D6B 0%, #005B9F 52%, #002847 100%);
box-shadow: 0 4px 16px rgba(0, 61, 107, 0.16);
color: #fff;
}
.wallet-hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.wallet-hero-brand {
display: flex;
align-items: center;
gap: 8px;
}
.wallet-hero-logo {
width: 22px;
height: 22px;
object-fit: contain;
}
.wallet-hero-brand-text {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.88);
}
.wallet-hero-recharge {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.32);
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-size: 13px;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
}
.wallet-hero-recharge:hover {
background: rgba(255, 255, 255, 0.2);
}
.wallet-hero-label {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
margin-bottom: 6px;
}
.wallet-hero-balance {
margin: 0;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: clamp(30px, 3.2vw, 42px);
font-weight: 700;
line-height: 1.1;
}
.wallet-stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.wallet-stat-card {
padding: 14px 16px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
}
.wallet-stat-label {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
max-width: 420px;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
.wallet-stat-value {
display: block;
font-size: 16px;
font-weight: 800;
color: var(--primary);
font-variant-numeric: tabular-nums;
word-break: break-all;
}
.wallet-panel {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-card);
overflow: hidden;
}
.wallet-panel-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
align-items: start;
}
.wallet-panel-row:last-child {
border-bottom: none;
}
.wallet-panel-row--actions {
display: flex;
justify-content: flex-end;
grid-template-columns: none;
}
.wallet-panel-label {
font-size: 12px;
font-weight: 800;
color: var(--text-muted);
padding-top: 2px;
}
.wallet-panel-content {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.wallet-info-line {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.wallet-info-key {
width: 64px;
flex-shrink: 0;
color: var(--text-muted);
font-weight: 600;
}
.wallet-info-val {
flex: 1;
font-weight: 700;
color: var(--text);
word-break: break-all;
}
.wallet-panel-link {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--border-active);
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
text-decoration: none;
}
.wallet-panel-link:hover {
background: rgba(0, 102, 204, 0.06);
}
.wallet-lang-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wallet-lang-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-body);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.wallet-lang-btn.active {
border-color: var(--border-active);
color: var(--primary-light);
background: rgba(0, 102, 204, 0.08);
}
.wallet-logout-btn {
padding: 8px 18px;
border-radius: 8px;
border: 1px solid rgba(220, 38, 38, 0.35);
background: transparent;
color: var(--danger);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.wallet-logout-btn:hover {
background: rgba(220, 38, 38, 0.06);
}
@media (max-width: 720px) {
.wallet-stat-grid {
grid-template-columns: 1fr;
}
.wallet-panel-row {
grid-template-columns: 1fr;
gap: 8px;
}
}
</style>