feat(player): 新增桌面端 UI 与响应式双端路由架构

- 将原移动端页面重命名为 Mobile*,新增 22 个 Desktop* 视图与 DesktopShell 布局体系
- 路由入口改为 Wrapper 视图,通过 useViewport(≥1024px)自动切换移动/桌面端
- 新增投注单面板、盘口弹层、联赛侧栏、数据表格等桌面组件及独立样式令牌
- 扩展 betSlip store、串关筛选、冠军盘、Toast/悬浮信箱等交互与三语 i18n
- 公告/消息 Hub 拆分移动与桌面实现;API 投注/钱包/充值记录支持 pageSize 分页
This commit is contained in:
2026-06-29 18:01:39 +08:00
parent 9c5e8d6f5c
commit 8a1ba0fd95
116 changed files with 22249 additions and 8693 deletions

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
/**
* Desktop Announcement Detail View
* Reuses the existing AnnouncementDetailView component in a desktop shell wrapper.
*/
import AnnouncementDetailView from '../AnnouncementDetailView.vue';
</script>
<template>
<div class="desktop-content-shell">
<div class="content-main">
<AnnouncementDetailView />
</div>
</div>
</template>
<style scoped>
.desktop-content-shell {
display: flex;
flex: 1;
min-height: 0;
}
.content-main {
flex: 1;
min-width: 0;
padding: 20px 24px 28px;
overflow-y: auto;
max-width: 760px;
}
</style>

View File

@@ -0,0 +1,327 @@
<script setup lang="ts">
import { computed, onActivated } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from '../../components/GoldSpinner.vue';
import defaultBannerImg from '../../assets/images/banner.webp';
import { usePlayerHome, type PlayerContentItem } from '../../composables/usePlayerHome';
import { stripHtml } from '../../utils/html';
const router = useRouter();
const { t, locale } = useI18n();
const { hubContentItems, loading, load } = usePlayerHome();
const items = computed(() => hubContentItems.value);
function goBack() {
if (window.history.length > 1) {
router.back();
return;
}
router.push('/');
}
function openDetail(id: string) {
router.push(`/announcements/${id}`);
}
function formatDate(createdAt?: string) {
if (!createdAt) return '';
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
}
function itemTitle(item: PlayerContentItem) {
const title = item.translation?.title?.trim();
if (title) return title;
return stripHtml(item.translation?.body ?? '') || t('home.announcement_badge');
}
function itemPreview(item: PlayerContentItem) {
const title = item.translation?.title?.trim();
const bodyText = stripHtml(item.translation?.body ?? '');
if (bodyText && bodyText !== title) return bodyText;
return '';
}
function typeLabel(item: PlayerContentItem) {
switch (item.contentType) {
case 'BANNER':
return t('announcements.type_banner');
case 'TICKER':
return t('announcements.type_ticker');
default:
return t('announcements.type_notice');
}
}
function coverUrl(item: PlayerContentItem) {
const url = item.translation?.imageUrl?.trim();
if (url) return url;
if (item.contentType === 'BANNER') return defaultBannerImg;
return '';
}
onActivated(() => {
void load(true);
});
</script>
<template>
<div class="desktop-announce-center">
<header class="center-hero">
<button type="button" class="back-btn" @click="goBack">
<span class="back-icon" aria-hidden="true"></span>
{{ t('announcements.back') }}
</button>
<div class="hero-main">
<div class="hero-title-row">
<svg class="hero-icon" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
</svg>
<h1>{{ t('announcements.title') }}</h1>
</div>
<p class="hero-sub">{{ t('announcements.center_desc') }}</p>
</div>
<div v-if="items.length" class="hero-count">{{ t('announcements.total_count', { count: items.length }) }}</div>
</header>
<div v-if="loading && !items.length" class="state-wrap">
<GoldSpinner :size="40" />
</div>
<div v-else-if="!items.length" class="empty-wrap">
<p>{{ t('announcements.empty') }}</p>
</div>
<div v-else class="announce-grid">
<button
v-for="item in items"
:key="item.id"
type="button"
class="announce-card"
@click="openDetail(item.id)"
>
<div v-if="coverUrl(item)" class="card-cover">
<img :src="coverUrl(item)" :alt="itemTitle(item)" loading="lazy" />
</div>
<div class="card-body">
<div class="card-top">
<span class="type-badge">{{ typeLabel(item) }}</span>
<span v-if="item.createdAt" class="card-date">{{ formatDate(item.createdAt) }}</span>
</div>
<h2 class="card-title">{{ itemTitle(item) }}</h2>
<p v-if="itemPreview(item)" class="card-preview">{{ itemPreview(item) }}</p>
<span class="card-more">{{ t('announcements.read_more') }} </span>
</div>
</button>
</div>
</div>
</template>
<style scoped>
.desktop-announce-center {
width: 100%;
max-width: 1180px;
margin: 0 auto;
padding: 8px 0 40px;
}
.center-hero {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
padding: 18px 20px;
border: 1px solid var(--border);
border-radius: 10px;
background: linear-gradient(135deg, rgba(212, 175, 55, 0.08) 0%, rgba(18, 18, 18, 0.95) 42%);
}
.back-btn {
flex-shrink: 0;
margin-top: 2px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #141414;
color: var(--gold);
font-size: 13px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.back-btn:hover {
border-color: var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
}
.back-icon {
font-size: 18px;
line-height: 1;
}
.hero-main {
flex: 1;
min-width: 0;
}
.hero-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.hero-icon {
width: 22px;
height: 22px;
color: var(--primary-light);
}
.center-hero h1 {
margin: 0;
font-size: 22px;
font-weight: 800;
color: var(--primary-light);
}
.hero-sub {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
}
.hero-count {
flex-shrink: 0;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
font-size: 12px;
font-weight: 700;
color: var(--primary-light);
white-space: nowrap;
}
.state-wrap,
.empty-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 240px;
color: var(--text-muted);
font-size: 14px;
}
.announce-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
@media (max-width: 1100px) {
.announce-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.announce-card {
display: flex;
flex-direction: column;
min-height: 100%;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: rgba(18, 18, 18, 0.96);
text-align: left;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
.announce-card:hover {
border-color: var(--border-gold-soft);
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
}
.card-cover {
aspect-ratio: 16 / 7;
background: #0a0a0a;
overflow: hidden;
}
.card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 14px 16px;
flex: 1;
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.type-badge {
flex-shrink: 0;
padding: 2px 7px;
border-radius: 4px;
background: rgba(212, 175, 55, 0.12);
border: 1px solid rgba(212, 175, 55, 0.22);
font-size: 10px;
font-weight: 700;
color: var(--gold);
}
.card-date {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
.card-title {
margin: 0;
font-size: 15px;
font-weight: 800;
line-height: 1.45;
color: #fff;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-preview {
margin: 0;
font-size: 12px;
line-height: 1.55;
color: var(--text-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-more {
margin-top: auto;
padding-top: 4px;
font-size: 12px;
font-weight: 700;
color: var(--primary-light);
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import AnnouncementListView from '../AnnouncementListView.vue';
const route = useRoute();
const { t } = useI18n();
const isDetailRoute = computed(
() => route.path.startsWith('/announcements/') && Boolean(route.params.id),
);
</script>
<template>
<div class="desktop-layout-hub desktop-hub-page">
<aside class="desktop-hub-left">
<AnnouncementListView embedded />
</aside>
<main class="desktop-hub-right">
<slot v-if="isDetailRoute" />
<div v-else class="hub-placeholder">
{{ t('announcements.select_hint') }}
</div>
</main>
</div>
</template>
<style scoped>
.desktop-hub-page {
flex: 1;
min-height: 0;
}
.hub-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 280px;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter, RouterLink } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
function goBack() {
if (window.history.state && window.history.state.back) {
router.back();
} else {
router.push('/bets');
}
}
type BetDetail = {
betNo: string;
stake: string;
potentialPayout?: string;
status: string;
createdAt: string;
selections: Array<{
matchName?: string;
selectionName?: string;
odds?: number;
market?: string;
homeScore?: number;
awayScore?: number;
matchStatus?: string;
[key: string]: any;
}>;
[key: string]: any;
};
const detail = ref<BetDetail | null>(null);
const loading = ref(true);
const error = ref(false);
async function loadDetail() {
const betNo = route.params.betNo as string;
if (!betNo) return;
loading.value = true;
error.value = false;
try {
const { data } = await api.get(`/player/bets/${betNo}`);
detail.value = data.data ?? null;
} catch {
error.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadDetail);
function statusClass(status: string) {
const map: Record<string, string> = { WON: 'status-won', LOST: 'status-lost', PENDING: 'status-pending', PUSH: 'status-push' };
return map[status?.toUpperCase()] ?? 'status-default';
}
function statusLabel(status: string) {
const map: Record<string, string> = {
WON: t('history.filter_won'),
LOST: t('history.filter_lost'),
PENDING: t('history.filter_pending'),
PUSH: t('history.filter_push'),
};
return map[status?.toUpperCase()] ?? status;
}
</script>
<template>
<div class="desktop-account-page">
<main class="account-main">
<div class="page-header">
<button type="button" class="back-btn" @click="goBack">
{{ t('common.back') || '返回' }}
</button>
<h1 class="page-title">{{ t('bet.detail_title') || '注单详情' }}</h1>
</div>
<div v-if="loading" class="loading-state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="error" class="error-state">
<p>{{ t('common.load_failed') }}</p>
<button type="button" class="retry-btn" @click="loadDetail">{{ t('common.retry') }}</button>
</div>
<div v-else-if="detail" class="detail-layout">
<!-- Header info -->
<div class="detail-card header-card">
<div class="bet-no">{{ detail.betNo }}</div>
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
<div class="amount-row">
<div class="amount-item">
<span class="amount-label">{{ t('bet.stake') || '投注额' }}</span>
<span class="amount-val">{{ detail.stake }}</span>
</div>
<div class="amount-item">
<span class="amount-label">{{ t('bet.potential_payout') || '预计派彩' }}</span>
<span class="amount-val gold">{{ detail.potentialPayout || '-' }}</span>
</div>
<div class="amount-item">
<span class="amount-label">{{ t('bet.placed_at') || '下注时间' }}</span>
<span class="amount-val date">{{ new Date(detail.createdAt).toLocaleString() }}</span>
</div>
</div>
</div>
<!-- Selections -->
<div class="detail-card">
<div class="card-title">{{ t('bet.selections') || '投注选项' }}</div>
<div v-for="(sel, idx) in detail.selections" :key="idx" class="sel-row">
<div class="sel-match">{{ sel.matchName || t('bet.match') }}</div>
<div class="sel-meta">
<span class="sel-market">{{ sel.market || sel.marketName || '' }}</span>
<span class="sel-name">{{ sel.selectionName || '' }}</span>
<span class="sel-odds">@{{ sel.odds }}</span>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">{{ t('common.not_found') || '未找到记录' }}</div>
</main>
</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; overflow-y: auto; }
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.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; }
.error-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 80px 20px; color: var(--text-muted); }
.retry-btn { padding: 8px 24px; border-radius: 6px; border: 1px solid var(--primary); background: transparent; color: var(--primary-light); font-size: 13px; font-weight: 700; cursor: pointer; }
.empty-state { display: flex; align-items: center; justify-content: center; padding: 80px 20px; color: var(--text-muted); font-size: 14px; font-weight: 600; }
.detail-layout { display: flex; flex-direction: column; gap: 16px; max-width: 680px; }
.detail-card {
background: var(--desktop-sidebar-bg);
border: 1px solid var(--desktop-border);
border-radius: 12px;
padding: 20px;
}
.header-card { display: flex; flex-direction: column; gap: 12px; }
.bet-no {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.status-badge { display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: 12px; font-weight: 700; align-self: flex-start; }
.status-won { background: rgba(52,199,89,0.12); color: #4cd964; }
.status-lost { background: rgba(255,69,58,0.12); color: #ff453a; }
.status-pending { background: rgba(212,175,55,0.12); color: var(--primary-light); }
.status-push { background: rgba(100,100,100,0.12); color: #aaa; }
.status-default { background: rgba(100,100,100,0.1); color: #888; }
.amount-row { display: flex; gap: 32px; padding-top: 4px; flex-wrap: wrap; }
.amount-item { display: flex; flex-direction: column; gap: 4px; }
.amount-label { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.amount-val { font-size: 18px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
.amount-val.gold { color: var(--primary-light); }
.amount-val.date { font-size: 13px; color: var(--text-muted); font-weight: 600; }
.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: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); }
.sel-name { color: var(--text); font-weight: 700; }
.sel-odds { color: var(--primary-light); font-weight: 800; margin-left: auto; }
</style>

View File

@@ -0,0 +1,183 @@
<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 { formatMoney, formatMoneyCompact } from '../../utils/localeDisplay';
import { parseCashbackApiData, type CashbackRecord } from '../../utils/cashback';
const COL_COUNT = 6;
const { t, locale } = useI18n();
const allItems = ref<CashbackRecord[]>([]);
const loading = ref(true);
const page = ref(1);
const pageSize = ref(20);
const total = computed(() => allItems.value.length);
const items = computed(() => {
const start = (page.value - 1) * pageSize.value;
return allItems.value.slice(start, start + pageSize.value);
});
const totalAmount = computed(() =>
allItems.value.reduce((sum, row) => sum + Math.abs(parseFloat(row.amount) || 0), 0),
);
function formatPeriod(start: string, end: string) {
const opts: Intl.DateTimeFormatOptions = { month: '2-digit', day: '2-digit' };
const s = new Date(start).toLocaleDateString(locale.value, opts);
const e = new Date(end).toLocaleDateString(locale.value, opts);
return `${s} ${e}`;
}
function formatRate(rate: string) {
const n = parseFloat(rate);
if (!Number.isFinite(n)) return rate;
return `${(n * 100).toFixed(2)}%`;
}
function formatTime(value: string | null) {
if (!value) return '—';
return new Date(value).toLocaleString(locale.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
async function loadData() {
loading.value = true;
try {
const { data } = await api.get('/player/cashbacks');
allItems.value = parseCashbackApiData(data.data);
const maxPage = Math.max(1, Math.ceil(allItems.value.length / pageSize.value));
if (page.value > maxPage) page.value = maxPage;
} catch {
allItems.value = [];
page.value = 1;
} finally {
loading.value = false;
}
}
function onPageChange(p: number) {
page.value = p;
}
watch(pageSize, () => {
page.value = 1;
});
onMounted(loadData);
onActivated(loadData);
</script>
<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>
<section class="desktop-records-panel">
<div class="desktop-records-table-wrap">
<table class="desktop-records-table">
<thead>
<tr>
<th>{{ t('cashback.period') }}</th>
<th>{{ t('cashback.effective_stake') }}</th>
<th>{{ t('cashback.rate') }}</th>
<th class="num-th">{{ t('cashback.amount') }}</th>
<th>{{ t('cashback.batch_no') }}</th>
<th>{{ t('wallet.time') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loading" class="state-row">
<td :colspan="COL_COUNT">
<div class="desktop-records-loading-cell">
<GoldSpinner :size="36" />
</div>
</td>
</tr>
<template v-else-if="items.length">
<tr v-for="row in items" :key="row.id" class="hover-row">
<td>{{ formatPeriod(row.periodStart, row.periodEnd) }}</td>
<td>{{ formatMoneyCompact(row.effectiveStake, locale) }}</td>
<td>{{ formatRate(row.rate) }}</td>
<td class="num-cell desktop-records-amount pos">{{ formatMoney(row.amount, locale) }}</td>
<td class="batch-cell">{{ row.batchNo }}</td>
<td class="date-cell">{{ formatTime(row.confirmedAt ?? row.createdAt) }}</td>
</tr>
</template>
<tr v-else class="state-row">
<td :colspan="COL_COUNT" class="desktop-records-table-empty">
<div>{{ t('cashback.empty') }}</div>
<p class="empty-hint">{{ t('cashback.empty_hint') }}</p>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!loading && total > 0" class="desktop-records-pagination">
<Pagination
v-model="page"
v-model:pageSize="pageSize"
:total="total"
@change="onPageChange"
/>
</div>
</section>
</div>
</template>
<style scoped>
.summary-chip {
display: inline-flex;
align-items: baseline;
gap: 8px;
padding: 8px 14px;
border: 1px solid rgba(212, 175, 55, 0.22);
border-radius: 8px;
background: rgba(212, 175, 55, 0.08);
}
.summary-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 700;
}
.summary-value {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
font-variant-numeric: tabular-nums;
}
.batch-cell {
font-family: 'SF Mono', Consolas, monospace;
font-size: 12px;
color: var(--text-muted);
}
.empty-hint {
margin: 8px 0 0;
font-size: 13px;
font-weight: 500;
line-height: 1.6;
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,372 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMatchFilters } from '../../composables/useMatchFilters';
import { useDesktopParlayMatches, type DesktopParlayMatch } from '../../composables/useDesktopParlayMatches';
import { useOutrightEvents } from '../../composables/useOutrightEvents';
import MatchBetCard from '../../components/MatchBetCard.vue';
import DesktopOutrightEventCard from '../../components/desktop/DesktopOutrightEventCard.vue';
import {
isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow,
isInLocalTodayMatchWindow as isInTodayMatchWindow,
} from '@thebet365/shared';
type MainTab = 'matches' | 'outright';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const { parlayMatches, parlayLoading, loadParlayMatches } = useDesktopParlayMatches();
const { searchQuery, filterState } = useMatchFilters();
const { loading: outrightLoading, loadError: outrightError, events: outrightEvents, load: loadOutrights } =
useOutrightEvents();
const mainTab = ref<MainTab>('matches');
watch(
() => route.query.tab,
(tab) => {
mainTab.value = tab === 'outright' ? 'outright' : 'matches';
},
{ immediate: true },
);
function setMainTab(tab: MainTab) {
if (mainTab.value === tab && (tab === 'outright') === (route.query.tab === 'outright')) {
return;
}
mainTab.value = tab;
const query = { ...route.query };
if (tab === 'outright') query.tab = 'outright';
else delete query.tab;
router.replace({ path: route.path, query });
}
watch(mainTab, (tab) => {
if (tab === 'outright') {
void loadOutrights({ silent: outrightEvents.value.length > 0 });
}
});
function normalizeLeagueName(name: string): string {
return name
.replace(/\.unit$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function matchesSearchKeyword(m: DesktopParlayMatch, keyword: string) {
if (!keyword) return true;
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
return haystack.includes(keyword);
}
const filteredMatches = computed(() => {
if (mainTab.value !== 'matches') return [];
const now = new Date();
const keyword = searchQuery.value.trim().toLowerCase();
return parlayMatches.value.filter((m) => {
if (!matchesSearchKeyword(m, keyword)) return false;
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, now);
} else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, now);
}
if (!timeMatch) return false;
if (filterState.value.status === 'open' && m.matchPhase !== 'open' && m.matchPhase !== undefined) return false;
if (filterState.value.status === 'settled' && m.matchPhase !== 'settled') return false;
if (filterState.value.leagueIds.length > 0) {
const id = m.leagueId ?? m.leagueName;
if (!filterState.value.leagueIds.includes(id)) return false;
}
return true;
});
});
const leagueGroups = computed(() => {
const groups: Record<string, { leagueName: string; matches: DesktopParlayMatch[] }> = {};
for (const m of filteredMatches.value) {
const id = m.leagueId ?? m.leagueName;
if (!groups[id]) {
groups[id] = { leagueName: normalizeLeagueName(m.leagueName), matches: [] };
}
groups[id].matches.push(m);
}
return Object.values(groups).sort((a, b) => a.leagueName.localeCompare(b.leagueName));
});
function goMatch(id: string) {
router.push(`/match/${id}`);
}
function goOutright(id: string) {
router.push(`/outright/${id}`);
}
async function refresh() {
if (mainTab.value === 'outright') {
await loadOutrights({ silent: false });
return;
}
await loadParlayMatches(true);
}
void loadParlayMatches(true);
</script>
<template>
<div class="desktop-football-view">
<div class="view-header">
<div class="main-tabs">
<button
type="button"
class="main-tab"
:class="{ active: mainTab === 'matches' }"
@click="setMainTab('matches')"
>
{{ t('bet.tab_matches') }}
</button>
<button
type="button"
class="main-tab"
:class="{ active: mainTab === 'outright' }"
@click="setMainTab('outright')"
>
🏆 {{ t('bet.tab_outright') }}
</button>
</div>
<button
type="button"
class="refresh-btn"
:disabled="mainTab === 'matches' ? parlayLoading : outrightLoading"
@click="refresh"
>
<span v-if="(mainTab === 'matches' ? parlayLoading : outrightLoading)" class="spin"></span>
<span v-else> {{ t('bet.refresh') }}</span>
</button>
</div>
<div v-if="mainTab === 'matches'" class="matches-list-panel">
<div v-if="parlayLoading && !leagueGroups.length" class="skeleton-container">
<div v-for="i in 6" :key="i" class="skeleton-card desktop-skeleton"></div>
</div>
<template v-else-if="leagueGroups.length">
<section v-for="lg in leagueGroups" :key="lg.leagueName" class="league-section">
<h2 class="league-title">{{ lg.leagueName }}</h2>
<div class="match-grid">
<MatchBetCard
v-for="m in lg.matches"
:key="m.id"
class="match-card-dense"
:match="m"
@bet="goMatch"
/>
</div>
</section>
</template>
<div v-else class="empty-state">
<p>{{ t('bet.no_matches') }}</p>
</div>
</div>
<div v-else class="outright-panel-wrap">
<div v-if="outrightLoading && !outrightEvents.length" class="skeleton-container">
<div v-for="i in 4" :key="i" class="skeleton-card desktop-skeleton"></div>
</div>
<template v-else-if="outrightEvents.length">
<p v-if="outrightEvents.length > 1" class="outright-summary">
{{
t('bet.outright_events_summary', {
events: outrightEvents.length,
teams: outrightEvents.reduce((n, e) => n + e.selections.length, 0),
})
}}
</p>
<div class="outright-event-grid">
<DesktopOutrightEventCard
v-for="event in outrightEvents"
:key="event.id"
:event="event"
@open="goOutright(event.id)"
/>
</div>
</template>
<div v-else class="empty-state">
<p>{{ outrightError || t('bet.no_outright') }}</p>
<p v-if="!outrightError" class="empty-hint">{{ t('bet.no_outright_hint') }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.desktop-football-view {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
box-sizing: border-box;
min-height: 100%;
}
.view-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.main-tabs {
display: flex;
gap: 10px;
}
.main-tab {
background: transparent;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
padding: 5px 10px;
border-radius: 4px;
transition: all 0.2s;
}
.main-tab:hover {
color: #fff;
background: rgba(255, 255, 255, 0.04);
}
.main-tab.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
border: 1px solid var(--border-gold-soft);
}
.refresh-btn {
background: transparent;
color: var(--primary-light);
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
}
.spin {
display: inline-block;
animation: spin 1s linear infinite;
}
@keyframes spin {
100% { transform: rotate(360deg); }
}
.skeleton-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 8px;
}
.skeleton-card {
height: 120px;
border-radius: 6px;
}
.league-section + .league-section {
margin-top: 14px;
}
.league-title {
margin: 0 0 8px;
padding: 6px 10px;
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
background: rgba(212, 175, 55, 0.06);
border-left: 3px solid var(--primary);
border-radius: 0 4px 4px 0;
}
.match-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 8px;
}
.match-grid :deep(.match-card) {
padding: 8px 6px;
gap: 6px;
}
.match-grid :deep(.team-name) {
font-size: 13px;
}
.match-grid :deep(.team-flag) {
width: 56px;
height: 38px;
}
.match-grid :deep(.team-flag.flag-logo) {
width: 44px;
height: 44px;
object-fit: contain;
}
.match-grid :deep(.kickoff) {
font-size: 10px;
}
.match-grid :deep(.vs) {
font-size: 11px;
}
.match-grid :deep(.bet-btn) {
font-size: 11px;
padding: 5px 10px;
min-height: 28px;
}
.match-grid :deep(.status-tag) {
font-size: 9px;
padding: 2px 6px;
}
.empty-state {
text-align: center;
padding: 40px 16px;
color: var(--text-muted);
font-size: 13px;
}
.empty-hint {
margin-top: 8px;
font-size: 12px;
opacity: 0.85;
}
.outright-summary {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.outright-event-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
}
</style>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
/**
* Desktop Forgot Password View
*/
import ForgotPasswordView from '../ForgotPasswordView.vue';
</script>
<template>
<div class="desktop-auth-shell">
<div class="auth-brand">
<div class="brand-inner">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<h1 class="brand-name">THEBET365</h1>
<p class="brand-tagline">重置您的登录密码</p>
</div>
</div>
<div class="auth-card-wrap">
<div class="auth-card">
<ForgotPasswordView />
</div>
</div>
</div>
</template>
<style scoped>
.desktop-auth-shell {
display: flex;
min-height: 100vh;
background: var(--desktop-bg);
}
.auth-brand {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
border-right: 1px solid var(--desktop-border);
position: relative;
overflow: hidden;
}
.auth-brand::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(212,175,55,0.06) 0%, transparent 70%);
pointer-events: none;
}
.brand-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
position: relative;
z-index: 1;
padding: 40px;
}
.brand-logo {
width: 64px;
height: 64px;
object-fit: contain;
filter: drop-shadow(0 4px 16px rgba(212,175,55,0.3));
}
.brand-name {
font-size: 28px;
font-weight: 900;
letter-spacing: 0.12em;
color: var(--primary-light);
margin: 0;
}
.brand-tagline {
font-size: 14px;
color: var(--text-muted);
margin: 0;
font-weight: 600;
}
.auth-card-wrap {
width: 480px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: var(--desktop-bg);
overflow-y: auto;
}
.auth-card {
width: 100%;
max-width: 380px;
}
</style>

View File

@@ -0,0 +1,355 @@
<script setup lang="ts">
import { onMounted, computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import DesktopBanner3DCarousel from '../../components/desktop/DesktopBanner3DCarousel.vue';
import HomeAnnouncementCard from '../../components/HomeAnnouncementCard.vue';
import { usePlayerHome } from '../../composables/usePlayerHome';
import TeamEmblem from '../../components/TeamEmblem.vue';
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 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 emptyMessage = computed(() =>
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
);
onMounted(() => {
void load(true);
});
function goMatch(id: string) {
router.push(`/match/${id}`);
}
function formatKickoff(startTime: string) {
return formatLocalMatchDateTime(startTime, locale.value, { variant: 'full' });
}
</script>
<template>
<div class="desktop-home-view">
<section class="hero-banner-full">
<DesktopBanner3DCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
</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>
<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>
</button>
</div>
<!-- Loading State -->
<div v-if="loading && !displayedMatches.length" class="loading-state">
<GoldSpinner :active="true" />
</div>
<!-- Matches Grid -->
<div v-else-if="displayedMatches.length" class="matches-grid">
<div
v-for="match in displayedMatches"
:key="match.id"
class="match-card clickable"
@click="goMatch(match.id)"
>
<div class="card-top">
<span class="league-tag">{{ match.leagueName }}</span>
<span class="live-indicator" v-if="(match as any).status === 'LIVE'">LIVE</span>
</div>
<div class="teams-versus">
<div class="team-side home">
<TeamEmblem
size="md"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="team-name">{{ match.homeTeamName }}</span>
</div>
<div class="vs-divider">VS</div>
<div class="team-side away">
<TeamEmblem
size="md"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="team-name">{{ match.awayTeamName }}</span>
</div>
</div>
<div class="card-footer">
<span class="match-time">{{ formatKickoff(match.startTime) }}</span>
<button type="button" class="bet-action-btn">{{ t('nav.bet') || '投注' }}</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<p>{{ emptyMessage }}</p>
</div>
</div>
<aside class="home-sidebar">
<HomeAnnouncementCard />
</aside>
</div>
</div>
</template>
<style scoped>
.desktop-home-view {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
}
.hero-banner-full {
width: 100%;
}
.content-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 16px;
align-items: start;
}
.home-sidebar {
min-width: 0;
}
.home-sidebar :deep(.home-announce-card) {
border-radius: 12px;
}
.matches-hub {
background: rgba(20, 20, 20, 0.85);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.hub-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
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);
}
.refresh-btn {
background: transparent;
color: var(--primary-light);
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
}
.spin {
display: inline-block;
animation: spin 1s linear infinite;
}
@keyframes spin {
100% { transform: rotate(360deg); }
}
.loading-state,
.empty-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
color: var(--text-muted);
}
.matches-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
}
.match-card {
background: #141414;
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
transition: all 0.2s;
}
.match-card:hover {
border-color: var(--border-gold-soft);
box-shadow: var(--shadow-gold);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.league-tag {
font-size: 11px;
font-weight: 700;
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
padding: 2px 6px;
border-radius: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-indicator {
font-size: 10px;
font-weight: 800;
background: var(--danger);
color: #fff;
padding: 1px 4px;
border-radius: 2px;
}
.teams-versus {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.team-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex: 1;
}
.team-name {
font-size: 12px;
font-weight: 700;
color: #eee;
text-align: center;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vs-divider {
font-size: 11px;
font-weight: 800;
color: var(--text-muted);
background: #222;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.match-time {
font-size: 11px;
color: var(--text-muted);
}
.bet-action-btn {
padding: 4px 12px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: transparent;
color: var(--primary-light);
font-size: 11px;
font-weight: 700;
transition: all 0.2s;
}
.match-card:hover .bet-action-btn {
background: var(--primary);
color: #3D2800;
border-color: var(--primary);
}
</style>

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import MessageListPanel from '../../components/MessageListPanel.vue';
import CustomerServicePanel from '../../components/CustomerServicePanel.vue';
import ConfirmDialog from '../../components/ConfirmDialog.vue';
import { useAuthStore } from '../../stores/auth';
import { usePlayerMessages } from '../../composables/usePlayerMessages';
import { useInboxFeature } from '../../composables/useInboxFeature';
type HubTab = 'messages' | 'support';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const auth = useAuthStore();
const { inboxEnabled, hubTitleKey } = useInboxFeature();
const { unreadCount, messages, listLoaded, refreshUnreadCount, markAllRead, deleteAllMessages } = usePlayerMessages();
const markingAll = ref(false);
const deletingAll = ref(false);
const deleteAllConfirmVisible = ref(false);
const activeTab = computed<HubTab>(() => {
if (!inboxEnabled.value) return 'support';
return route.query.tab === 'support' ? 'support' : 'messages';
});
const unreadInList = computed(() => messages.value.filter((item) => !item.isRead).length);
const hasMessages = computed(() => listLoaded.value && messages.value.length > 0);
const showMessageActions = computed(
() => inboxEnabled.value && activeTab.value === 'messages' && auth.token && hasMessages.value,
);
function switchTab(tab: HubTab) {
if (!inboxEnabled.value || tab === activeTab.value) return;
router.replace({ path: '/messages', query: tab === 'support' ? { tab: 'support' } : {} });
}
async function onMarkAllRead() {
if (!unreadInList.value || markingAll.value) return;
markingAll.value = true;
try {
await markAllRead();
await refreshUnreadCount();
} finally {
markingAll.value = false;
}
}
function onDeleteAll() {
if (deletingAll.value || !messages.value.length) return;
deleteAllConfirmVisible.value = true;
}
async function confirmDeleteAll() {
deletingAll.value = true;
deleteAllConfirmVisible.value = false;
try {
await deleteAllMessages();
await refreshUnreadCount();
} finally {
deletingAll.value = false;
}
}
onMounted(refreshUnreadCount);
onActivated(refreshUnreadCount);
</script>
<template>
<div class="desktop-hub-shell">
<!-- Sidebar: tabs -->
<aside class="hub-aside">
<nav class="hub-nav">
<div class="hub-nav-title">{{ t(hubTitleKey) }}</div>
<button
v-if="inboxEnabled"
type="button"
class="hub-tab hover-bg"
:class="{ active: activeTab === 'messages' }"
@click="switchTab('messages')"
>
<span class="dot" />
<span>{{ t('messages.tab_inbox') }}</span>
<span v-if="unreadInList > 0" class="badge">{{ unreadInList }}</span>
</button>
<button
type="button"
class="hub-tab hover-bg"
:class="{ active: activeTab === 'support' }"
@click="switchTab('support')"
>
<span class="dot" />
<span>{{ t('messages.tab_support') }}</span>
</button>
</nav>
</aside>
<!-- Main content -->
<main class="hub-main">
<div class="hub-header">
<h1 class="hub-title">
{{ activeTab === 'messages' ? t('messages.tab_inbox') : t('messages.tab_support') }}
</h1>
<div v-if="showMessageActions" class="hub-actions">
<button
v-if="unreadInList > 0"
type="button"
class="action-link"
:disabled="markingAll"
@click="onMarkAllRead"
>
{{ t('messages.mark_all_read') }}
</button>
<button
type="button"
class="action-link danger"
:disabled="deletingAll"
@click="onDeleteAll"
>
{{ t('messages.delete_all') }}
</button>
</div>
</div>
<MessageListPanel v-if="activeTab === 'messages' && inboxEnabled" class="hub-panel" @click-message="id => $router.push('/messages/' + id)" />
<CustomerServicePanel v-else class="hub-panel" />
</main>
<ConfirmDialog
v-if="deleteAllConfirmVisible"
:visible="deleteAllConfirmVisible"
:message="t('messages.delete_all_confirm')"
@confirm="confirmDeleteAll"
@cancel="deleteAllConfirmVisible = false"
/>
</div>
</template>
<style scoped>
.desktop-hub-shell {
display: flex;
min-height: 0;
flex: 1;
}
.hub-aside {
width: 220px;
flex-shrink: 0;
border-right: 1px solid var(--desktop-border);
background: var(--desktop-sidebar-bg);
padding: 10px;
}
.hub-nav-title {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 6px 14px 10px;
}
.hub-tab {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
border-radius: 4px;
border: none;
background: none;
color: var(--text-muted);
font-size: 13px;
font-weight: 700;
cursor: pointer;
text-align: left;
transition: all 0.2s;
position: relative;
}
.hub-tab:hover { color: #fff; }
.hub-tab.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
}
.hub-tab.active .dot { background: var(--primary); box-shadow: 0 0 8px var(--primary); }
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #333;
flex-shrink: 0;
transition: all 0.2s;
}
.badge {
margin-left: auto;
background: rgba(255, 69, 58, 0.85);
color: #fff;
font-size: 10px;
font-weight: 800;
min-width: 18px;
height: 18px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.hub-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.hub-header {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--desktop-border);
}
.hub-title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
margin: 0;
flex: 1;
}
.hub-actions {
display: flex;
gap: 12px;
}
.action-link {
background: none;
border: none;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
cursor: pointer;
transition: color 0.2s;
padding: 0;
}
.action-link:hover:not(:disabled) { color: var(--primary-light); }
.action-link.danger:hover:not(:disabled) { color: var(--danger); }
.action-link:disabled { opacity: 0.4; cursor: default; }
.hub-panel {
flex: 1;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
/**
* Desktop Login View
* Reuses the existing LoginView form in a centered two-column auth layout.
*/
import LoginView from '../LoginView.vue';
</script>
<template>
<div class="desktop-auth-shell">
<div class="auth-brand">
<div class="brand-inner">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<h1 class="brand-name">THEBET365</h1>
<p class="brand-tagline">专业体育赛事投注平台</p>
<div class="brand-features">
<div class="feature-item">
<span class="feature-icon"></span>
<span>实时赔率更新</span>
</div>
<div class="feature-item">
<span class="feature-icon">🔒</span>
<span>安全稳定平台</span>
</div>
<div class="feature-item">
<span class="feature-icon">💰</span>
<span>快速充值提款</span>
</div>
</div>
</div>
</div>
<div class="auth-card-wrap">
<div class="auth-card">
<LoginView />
</div>
</div>
</div>
</template>
<style scoped>
.desktop-auth-shell {
display: flex;
min-height: 100vh;
background: var(--desktop-bg);
}
.auth-brand {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
border-right: 1px solid var(--desktop-border);
position: relative;
overflow: hidden;
}
.auth-brand::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(212,175,55,0.06) 0%, transparent 70%);
pointer-events: none;
}
.brand-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
position: relative;
z-index: 1;
padding: 40px;
}
.brand-logo {
width: 64px;
height: 64px;
object-fit: contain;
filter: drop-shadow(0 4px 16px rgba(212,175,55,0.3));
}
.brand-name {
font-size: 28px;
font-weight: 900;
letter-spacing: 0.12em;
color: var(--primary-light);
margin: 0;
text-shadow: 0 2px 12px rgba(212,175,55,0.25);
}
.brand-tagline {
font-size: 14px;
color: var(--text-muted);
margin: 0;
font-weight: 600;
}
.brand-features {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 24px;
}
.feature-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-muted);
font-weight: 600;
}
.feature-icon {
font-size: 16px;
}
.auth-card-wrap {
width: 480px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: var(--desktop-bg);
}
.auth-card {
width: 100%;
max-width: 380px;
}
</style>

View File

@@ -0,0 +1,771 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import { useBetSlipStore } from '../../stores/betSlip';
import { useAuthStore } from '../../stores/auth';
import TeamEmblem from '../../components/TeamEmblem.vue';
import MatchBetGuide from '../../components/match-detail/MatchBetGuide.vue';
import MarketSelectionsPanel from '../../components/match-detail/MarketSelectionsPanel.vue';
import CorrectScorePanel from '../../components/match-detail/CorrectScorePanel.vue';
import { isCorrectScoreMarket, parseScoreCode } from '../../utils/correctScoreLayout';
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { matchPhaseLabel } from '../../utils/matchPhase';
import { formatLocalMatchDateTime, defaultMarketName, DETAIL_MARKET_TYPES } from '@thebet365/shared';
import { resolveSelectionLabel } from '../../utils/selectionLabel';
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
import { useDesktopBetLocate, scrollToBetSelection } from '../../composables/useDesktopBetLocate';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const slip = useBetSlipStore();
const auth = useAuthStore();
const { openAt, visible: popoverVisible, pendingItem: popoverItem } = useDesktopBetPopover();
const { pendingLocate, clearLocate } = useDesktopBetLocate();
function goBack() {
if (window.history.state && window.history.state.back) {
router.back();
} else {
router.push('/bet');
}
}
interface Selection {
id: string;
selectionCode: string;
selectionName: string;
selectionDisplayName?: string;
odds: string;
oddsVersion: string;
}
interface Market {
id: string;
marketType: string;
marketDisplayName?: string;
lineValue?: string | number | null;
status: string;
allowSingle?: boolean;
allowParlay?: boolean;
promoLabel?: string | null;
selections: Selection[];
}
interface MatchDetail {
id: string;
leagueName?: string;
leagueLogoUrl?: string | null;
homeTeamName: string;
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamLogoUrl?: string | null;
awayTeamLogoUrl?: string | null;
startTime: string;
status?: string;
bettingOpen?: boolean;
matchPhase?: string;
score?: {
htHome: number | null;
htAway: number | null;
ftHome: number | null;
ftAway: number | null;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
} | null;
markets: Market[];
}
const match = ref<MatchDetail | null>(null);
const loading = ref(true);
const selectedMarketTypes = ref<Set<string>>(new Set());
async function loadMatch() {
loading.value = true;
try {
const { data } = await api.get(`/player/matches/${route.params.id}`);
match.value = data.data;
selectedMarketTypes.value = new Set();
} finally {
loading.value = false;
}
}
onMounted(() => {
void loadMatch();
});
useOnLocaleChange(loadMatch);
const kickoff = computed(() => {
if (!match.value) return '';
return formatLocalMatchDateTime(match.value.startTime, locale.value, { variant: 'full' });
});
const bettingOpen = computed(() => match.value?.bettingOpen !== false);
const matchPhase = computed<import('../../utils/matchPhase').MatchPhase | null>(() => (match.value?.matchPhase ?? (bettingOpen.value ? 'open' : 'closed_pending')) as import('../../utils/matchPhase').MatchPhase);
const phaseLabel = computed(() => matchPhaseLabel(t, matchPhase.value));
const liveScoreText = computed(() => {
const s = match.value?.score;
if (!s || s.ftHome == null || s.ftAway == null) return '';
return `${s.ftHome} - ${s.ftAway}`;
});
const resultStats = computed(() => {
const s = match.value?.score;
if (!s || matchPhase.value !== 'settled') return [];
const statVal = (val: number | null | undefined) => val == null ? '-' : String(val);
const pair = (h: number | null | undefined, a: number | null | undefined) => `${statVal(h)} - ${statVal(a)}`;
return [
{ key: 'ht', label: t('bet.result_ht'), value: pair(s.htHome, s.htAway) },
{ key: 'ft', label: t('bet.result_ft'), value: pair(s.ftHome, s.ftAway) },
{ key: 'corners', label: t('bet.result_corners'), value: pair(s.homeCorners, s.awayCorners) },
{ key: 'yellow', label: t('bet.result_yellow_cards'), value: pair(s.homeYellowCards, s.awayYellowCards) },
{ key: 'red', label: t('bet.result_red_cards'), value: pair(s.homeRedCards, s.awayRedCards) },
];
});
const showResultStats = computed(() => resultStats.value.length > 0);
const allMarkets = computed(() => match.value?.markets ?? []);
const compactMarkets = computed(() =>
allMarkets.value.filter((m) => !isCorrectScoreMarket(m.marketType)),
);
const scoreMarkets = computed(() =>
allMarkets.value.filter((m) => isCorrectScoreMarket(m.marketType)),
);
const marketFilterOptions = computed(() => {
const labels = new Map<string, string>();
for (const market of allMarkets.value) {
if (labels.has(market.marketType)) continue;
labels.set(
market.marketType,
market.marketDisplayName?.trim() || defaultMarketName(market.marketType, locale.value),
);
}
return Array.from(labels.entries())
.map(([type, label]) => ({ type, label }))
.sort((a, b) => marketTypeSortIndex(a.type) - marketTypeSortIndex(b.type));
});
const hasMarketFilter = computed(() => selectedMarketTypes.value.size > 0);
const filteredCompactMarkets = computed(() =>
compactMarkets.value.filter((market) => matchesMarketFilter(market.marketType)),
);
const filteredScoreMarkets = computed(() =>
scoreMarkets.value.filter((market) => matchesMarketFilter(market.marketType)),
);
function marketTypeSortIndex(marketType: string) {
const idx = (DETAIL_MARKET_TYPES as readonly string[]).indexOf(marketType);
return idx >= 0 ? idx : 999;
}
function matchesMarketFilter(marketType: string) {
if (!hasMarketFilter.value) return true;
return selectedMarketTypes.value.has(marketType);
}
function isMarketFilterActive(marketType: string) {
return selectedMarketTypes.value.has(marketType);
}
function isAllMarketFilterActive() {
return !hasMarketFilter.value;
}
function selectAllMarketFilters() {
selectedMarketTypes.value = new Set();
}
function toggleMarketFilter(marketType: string) {
const next = new Set(selectedMarketTypes.value);
if (next.has(marketType)) next.delete(marketType);
else next.add(marketType);
selectedMarketTypes.value = next;
}
function ensureMarketVisibleForLocate(target: { marketId?: string; selectionId: string }) {
const market =
allMarkets.value.find((m) => m.id === target.marketId) ??
allMarkets.value.find((m) => m.selections.some((s) => s.id === target.selectionId));
if (!market) return;
if (hasMarketFilter.value && !matchesMarketFilter(market.marketType)) {
selectedMarketTypes.value = new Set();
}
}
watch(
[pendingLocate, () => match.value?.id, loading],
async ([target, matchId, isLoading]) => {
if (!target || isLoading || !matchId) return;
if (String(matchId) !== String(target.matchId)) return;
ensureMarketVisibleForLocate(target);
await nextTick();
const found = await scrollToBetSelection(target.selectionId);
if (found) clearLocate();
},
{ flush: 'post' },
);
watch(
() => route.params.id,
() => {
selectedMarketTypes.value = new Set();
},
);
function marketLabel(market: Market) {
return market.marketDisplayName?.trim() || market.marketType || '';
}
function normalizeMarketStatus(status?: string) {
return (status ?? 'OPEN').toUpperCase();
}
function isMarketOpen(market: Market) {
return normalizeMarketStatus(market.status) === 'OPEN';
}
function marketStatusLabel(status?: string) {
const normalized = normalizeMarketStatus(status);
if (normalized === 'SUSPENDED') return t('bet.market_status_suspended');
if (normalized === 'CLOSED') return t('bet.market_status_closed');
return '';
}
function isMarketLocked(market: Market) {
if (!bettingOpen.value || !isMarketOpen(market)) return true;
return market.allowSingle !== true && market.allowParlay !== true;
}
function selectionLabel(sel: Selection, market: Market) {
const parsedScore = parseScoreCode(sel.selectionCode, t);
if (parsedScore) return parsedScore.display;
return resolveSelectionLabel(t, sel.selectionCode, sel.selectionName, {
lineValue: market.lineValue,
});
}
function isSelected(id: string) {
if (slip.isInSlip(id)) return true;
return popoverVisible.value && popoverItem.value?.selectionId === id;
}
function buildSlipItem(sel: Selection, market: Market) {
if (!match.value) return null;
return {
selectionId: sel.id,
oddsVersion: String(sel.oddsVersion),
matchId: match.value.id,
matchName: `${match.value.homeTeamName} vs ${match.value.awayTeamName}`,
marketId: market.id,
marketName: marketLabel(market),
selectionName: selectionLabel(sel, market),
odds: parseFloat(sel.odds),
marketType: market.marketType,
lineValue:
market.lineValue != null && market.lineValue !== ''
? parseFloat(String(market.lineValue))
: null,
allowSingle: market.allowSingle,
allowParlay: market.allowParlay,
};
}
function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
if (!match.value || isMarketLocked(market)) return;
if (!auth.token) {
auth.showLoginPrompt(route.fullPath);
return;
}
const item = buildSlipItem(sel, market);
if (!item || !event) return;
openAt(event, item);
}
</script>
<template>
<div class="desktop-match-detail">
<!-- Fixed Header Breadcrumb -->
<div class="detail-breadcrumbs">
<button type="button" class="back-btn" @click="goBack"> {{ t('bet.back') || '返回' }}</button>
<span class="sep">/</span>
<span class="curr">{{ match?.leagueName }}</span>
</div>
<div class="detail-main">
<!-- Loading -->
<div v-if="loading" class="loading-state">
<GoldSpinner :active="true" />
</div>
<template v-else-if="match">
<!-- High Density Score Band -->
<div class="match-score-band" :class="{ 'phase-settled': matchPhase === 'settled' }">
<div class="team-side home">
<TeamEmblem size="xl" :team-code="match.homeTeamCode" :team-name="match.homeTeamName" :logo-url="match.homeTeamLogoUrl" />
<span class="name">{{ match.homeTeamName }}</span>
</div>
<div class="score-arena">
<div class="versus-row" v-if="liveScoreText">
<span class="score">{{ match.score?.ftHome }}</span>
<span class="divider">-</span>
<span class="score">{{ match.score?.ftAway }}</span>
</div>
<div class="versus-row vs" v-else>VS</div>
<span class="kickoff-time">{{ kickoff }}</span>
</div>
<div class="team-side away">
<TeamEmblem size="xl" :team-code="match.awayTeamCode" :team-name="match.awayTeamName" :logo-url="match.awayTeamLogoUrl" />
<span class="name">{{ match.awayTeamName }}</span>
</div>
</div>
<!-- Result Stats -->
<div v-if="showResultStats" class="result-stats-card">
<h4>{{ t('bet.result_stats_title') || '赛果数据' }}</h4>
<div class="stats-row">
<div v-for="item in resultStats" :key="item.key" class="stat-pill">
<span class="lbl">{{ item.label }}</span>
<span class="val">{{ item.value }}</span>
</div>
</div>
</div>
<div v-if="marketFilterOptions.length" class="market-filter-bar">
<span class="market-filter-label">{{ t('bet.filter_market') }}</span>
<div class="market-filter-chips">
<button
type="button"
class="market-filter-chip"
:class="{ active: isAllMarketFilterActive() }"
@click="selectAllMarketFilters"
>
{{ t('bet.time_all') }}
</button>
<button
v-for="option in marketFilterOptions"
:key="option.type"
type="button"
class="market-filter-chip"
:class="{ active: isMarketFilterActive(option.type) }"
@click="toggleMarketFilter(option.type)"
>
{{ option.label }}
</button>
</div>
</div>
<!-- Standard markets: 2-col card grid -->
<div v-if="filteredCompactMarkets.length" class="markets-compact-grid">
<div
v-for="market in filteredCompactMarkets"
:key="market.id"
class="market-card"
:class="{ locked: isMarketLocked(market) }"
>
<div class="market-hdr">
<span class="market-title">{{ marketLabel(market) }}</span>
<span v-if="isMarketLocked(market)" class="locked-lbl">{{ t('bet.market_status_closed') }}</span>
</div>
<div class="market-body">
<MarketSelectionsPanel
desktop-card
compact
:locked="isMarketLocked(market)"
:line-value="market.lineValue"
:selections="market.selections"
:is-selected="isSelected"
@pick="(id, ev) => toggleSelection(market.selections.find(s => s.id === id)!, market, ev)"
/>
</div>
</div>
</div>
<!-- Correct score -->
<div v-if="filteredScoreMarkets.length" class="markets-score-section">
<h4 class="score-section-title">{{ t('bet.correct_score') }}</h4>
<div class="markets-score-grid">
<div
v-for="market in filteredScoreMarkets"
:key="market.id"
class="score-block"
:class="{ locked: isMarketLocked(market) }"
>
<div class="score-block-hdr">
<span>{{ marketLabel(market) }}</span>
<span v-if="isMarketLocked(market)" class="locked-lbl">{{ t('bet.market_status_closed') }}</span>
</div>
<CorrectScorePanel
dense
:market-type="market.marketType"
:selections="market.selections"
:locked="isMarketLocked(market)"
:is-selected="isSelected"
@pick="(id, ev) => toggleSelection(market.selections.find(s => s.id === id)!, market, ev)"
/>
</div>
</div>
</div>
<div
v-if="!filteredCompactMarkets.length && !filteredScoreMarkets.length"
class="markets-empty"
>
<p>{{ hasMarketFilter ? t('bet.no_markets_filtered') : (t('bet.no_markets') || '暂无可用盘口') }}</p>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.desktop-match-detail {
display: flex;
flex-direction: column;
max-width: 100%;
min-height: 100%;
}
.detail-breadcrumbs {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
font-size: 12px;
color: var(--text-muted);
background: #0c0c0c;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.detail-main {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 16px 14px;
}
.detail-breadcrumbs .sep {
flex-shrink: 0;
}
.detail-breadcrumbs .curr {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-weight: 700;
}
.back-btn {
flex-shrink: 0;
background: transparent;
color: var(--primary-light);
font-weight: 700;
border: none;
padding: 0;
cursor: pointer;
}
.back-btn:hover {
text-decoration: underline;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.match-score-band {
background: linear-gradient(180deg, #181818 0%, #111 100%);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.team-side {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.team-side.away {
flex-direction: row-reverse;
}
.team-side .name {
font-size: 13px;
font-weight: 800;
color: #fff;
}
.score-arena {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 240px;
}
.versus-row {
display: flex;
align-items: center;
gap: 16px;
}
.versus-row .score {
font-size: 32px;
font-weight: 900;
color: var(--primary-light);
text-shadow: 0 0 10px rgba(212, 175, 55, 0.3);
}
.versus-row .divider {
font-size: 24px;
color: var(--text-muted);
}
.versus-row.vs {
font-size: 22px;
font-weight: 800;
color: var(--text-muted);
}
.kickoff-time {
font-size: 11px;
color: var(--text-muted);
}
.result-stats-card {
background: rgba(20, 20, 20, 0.8);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
}
.result-stats-card h4 {
margin: 0 0 12px;
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
text-transform: uppercase;
}
.stats-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.stat-pill {
background: #141414;
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 12px;
display: flex;
gap: 10px;
font-size: 11px;
}
.stat-pill .lbl { color: var(--text-muted); }
.stat-pill .val { color: #fff; font-weight: 700; }
.market-filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(18, 18, 18, 0.85);
min-width: 0;
}
.market-filter-label {
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
color: var(--primary-light);
white-space: nowrap;
}
.market-filter-chips {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
min-width: 0;
flex: 1;
overflow-x: auto;
scrollbar-width: none;
}
.market-filter-chips::-webkit-scrollbar {
display: none;
}
.market-filter-chip {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 3px 7px;
border-radius: 4px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
transition: background 0.18s, border-color 0.18s, color 0.18s;
}
.market-filter-chip:hover {
color: #fff;
background: rgba(255, 255, 255, 0.06);
}
.market-filter-chip.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
border-color: var(--border-gold-soft);
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.08);
}
.markets-score-section {
margin-top: 4px;
}
.score-section-title {
margin: 0 0 8px;
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
}
.markets-empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
color: var(--text-muted);
font-size: 13px;
}
.markets-compact-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
align-items: start;
}
@media (min-width: 1360px) {
.markets-compact-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.market-card {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: rgba(18, 18, 18, 0.98);
align-self: start;
}
.market-card.locked {
opacity: 0.55;
}
.market-hdr {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #141414;
border-bottom: 1px solid var(--border);
}
.market-title {
font-size: 11px;
font-weight: 800;
color: #eee;
line-height: 1.25;
}
.market-body {
padding: 6px;
}
.markets-score-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 8px;
align-items: start;
margin-top: 4px;
}
.score-block {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: rgba(18, 18, 18, 0.98);
align-self: start;
}
.score-block.locked {
opacity: 0.55;
}
.score-block-hdr {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #141414;
border-bottom: 1px solid var(--border);
font-size: 11px;
font-weight: 800;
color: #eee;
}
.locked-lbl {
font-size: 10px;
color: var(--danger);
font-weight: 700;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed, onActivated, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import MessageListPanel from '../../components/MessageListPanel.vue';
import CustomerServicePanel from '../../components/CustomerServicePanel.vue';
import { usePlayerMessages } from '../../composables/usePlayerMessages';
import { useInboxFeature } from '../../composables/useInboxFeature';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { inboxEnabled, hubTitleKey } = useInboxFeature();
const { messages, refreshUnreadCount } = usePlayerMessages();
type HubTab = 'messages' | 'support';
const activeTab = computed<HubTab>(() => {
if (!inboxEnabled.value) return 'support';
return route.query.tab === 'support' ? 'support' : 'messages';
});
const unreadInList = computed(() => messages.value.filter((item) => !item.isRead).length);
const isDetailRoute = computed(() => route.path.startsWith('/messages/') && route.params.id);
const showPlaceholder = computed(
() => !isDetailRoute.value && activeTab.value === 'messages' && inboxEnabled.value,
);
const showSupportPanel = computed(() => !isDetailRoute.value && activeTab.value === 'support');
function switchTab(tab: HubTab) {
if (!inboxEnabled.value || tab === activeTab.value) return;
router.replace({ path: '/messages', query: tab === 'support' ? { tab: 'support' } : {} });
}
onMounted(refreshUnreadCount);
onActivated(refreshUnreadCount);
</script>
<template>
<div class="desktop-layout-hub desktop-hub-page">
<aside class="desktop-hub-left hub-aside">
<nav class="hub-nav">
<div class="hub-nav-title">{{ t(hubTitleKey) }}</div>
<button
v-if="inboxEnabled"
type="button"
class="hub-tab hover-bg"
:class="{ active: activeTab === 'messages' }"
@click="switchTab('messages')"
>
<span class="dot" />
<span>{{ t('messages.tab_inbox') }}</span>
<span v-if="unreadInList > 0" class="badge">{{ unreadInList }}</span>
</button>
<button
type="button"
class="hub-tab hover-bg"
:class="{ active: activeTab === 'support' }"
@click="switchTab('support')"
>
<span class="dot" />
<span>{{ t('messages.tab_support') }}
</span>
</button>
</nav>
<MessageListPanel v-if="activeTab === 'messages' && inboxEnabled" class="hub-list" @click-message="id => $router.push('/messages/' + id)" />
</aside>
<main class="desktop-hub-right">
<slot v-if="isDetailRoute" />
<CustomerServicePanel v-else-if="showSupportPanel" class="hub-support" />
<div v-else-if="showPlaceholder" class="hub-placeholder">
{{ t('messages.select_hint') }}
</div>
</main>
</div>
</template>
<style scoped>
.desktop-hub-page {
flex: 1;
min-height: 0;
}
.hub-aside {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
height: 100%;
}
.hub-nav {
flex-shrink: 0;
padding: 10px;
border-bottom: 1px solid var(--desktop-border);
}
.hub-nav-title {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 6px 14px 10px;
}
.hub-tab {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
border-radius: 4px;
border: none;
background: none;
color: var(--text-muted);
font-size: 13px;
font-weight: 700;
cursor: pointer;
text-align: left;
transition: color 0.2s, background 0.2s;
}
.hub-tab:hover {
color: #fff;
}
.hub-tab.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
}
.hub-tab.active .dot {
background: var(--primary);
box-shadow: 0 0 8px var(--primary);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #333;
flex-shrink: 0;
}
.badge {
margin-left: auto;
background: rgba(255, 69, 58, 0.85);
color: #fff;
font-size: 10px;
font-weight: 800;
min-width: 18px;
height: 18px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.hub-list {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.hub-support {
flex: 1;
min-height: 0;
}
.hub-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 280px;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,194 @@
<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 { type BetHistoryItem } from '../../components/BetHistoryCard.vue';
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
const COL_COUNT = 8;
const { t } = useI18n();
const items = ref<BetHistoryItem[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const initialLoading = ref(true);
const statusFilter = ref('');
const STATUS_FILTERS = [
{ key: '', label: 'history.filter_all' },
{ key: 'WON', label: 'history.filter_won' },
{ key: 'LOST', label: 'history.filter_lost' },
{ key: 'PENDING', label: 'history.filter_pending' },
] as const;
const PUSH_FILTER = { key: 'PUSH', label: 'history.filter_push' } as const;
async function loadPage(p: number) {
if (loading.value) return;
loading.value = true;
try {
const params: Record<string, unknown> = { page: p, pageSize: pageSize.value };
if (statusFilter.value) params.status = statusFilter.value;
const { data } = await api.get('/player/bets', { params });
const result = data.data ?? { items: [], total: 0, pageSize: pageSize.value };
total.value = result.total ?? 0;
items.value = result.items ?? [];
page.value = p;
} finally {
loading.value = false;
initialLoading.value = false;
}
}
function changeFilter(key: string) {
if (statusFilter.value === key) return;
statusFilter.value = key;
page.value = 1;
initialLoading.value = true;
void loadPage(1);
}
useOnLocaleChange(() => {
page.value = 1;
initialLoading.value = true;
void loadPage(1);
});
onActivated(() => { void loadPage(page.value); });
onMounted(() => { void loadPage(1); });
function statusClass(status: string) {
const map: Record<string, string> = {
WON: 'status-won',
LOST: 'status-lost',
PENDING: 'status-pending',
PUSH: 'status-push',
CANCELLED: 'status-cancelled',
};
return map[status?.toUpperCase()] ?? 'status-default';
}
function statusLabel(status: string) {
const map: Record<string, string> = {
WON: t('history.filter_won'),
LOST: t('history.filter_lost'),
PENDING: t('history.filter_pending'),
PUSH: t('history.filter_push'),
CANCELLED: t('common.cancelled'),
};
return map[status?.toUpperCase()] ?? status;
}
</script>
<template>
<div class="desktop-records-page">
<header class="desktop-records-header">
<div class="desktop-records-header-main">
<div class="desktop-records-filter-groups">
<div class="desktop-records-filter-group">
<span class="desktop-records-filter-group-label">{{ t('history.filter_status_group') }}</span>
<div class="desktop-records-filter-bar">
<button
v-for="f in STATUS_FILTERS"
:key="f.key"
type="button"
class="desktop-records-filter-chip"
:class="{ active: statusFilter === f.key }"
@click="changeFilter(f.key)"
>
{{ t(f.label) }}
</button>
</div>
</div>
<span class="desktop-records-filter-divider" aria-hidden="true" />
<div class="desktop-records-filter-group">
<span class="desktop-records-filter-group-label">{{ t('history.filter_push_group') }}</span>
<div class="desktop-records-filter-bar">
<button
type="button"
class="desktop-records-filter-chip"
:class="{ active: statusFilter === PUSH_FILTER.key }"
:title="t('history.filter_push_hint')"
@click="changeFilter(PUSH_FILTER.key)"
>
{{ t(PUSH_FILTER.label) }}
</button>
</div>
</div>
</div>
</div>
</header>
<section class="desktop-records-panel">
<div class="desktop-records-table-wrap">
<table class="desktop-records-table">
<thead>
<tr>
<th>{{ t('bet.bet_no') }}</th>
<th>{{ t('bet.match') }}</th>
<th>{{ t('bet.selection') }}</th>
<th class="num-th">{{ t('bet.odds') }}</th>
<th class="num-th">{{ t('bet.stake') }}</th>
<th class="num-th">{{ t('bet.potential_payout') }}</th>
<th>{{ t('common.status') }}</th>
<th>{{ t('bet.placed_at') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="initialLoading || loading" class="state-row">
<td :colspan="COL_COUNT">
<div class="desktop-records-loading-cell">
<GoldSpinner :size="36" />
</div>
</td>
</tr>
<template v-else-if="items.length">
<tr v-for="bet in items" :key="bet.betNo" class="hover-row">
<td>
<RouterLink :to="`/bets/${bet.betNo}`" class="link-cell">{{ bet.betNo }}</RouterLink>
</td>
<td>{{ bet.matchTitle || '-' }}</td>
<td class="muted-cell">{{ bet.pickLabel || '-' }}</td>
<td class="num-cell">{{ bet.totalOdds ?? '-' }}</td>
<td class="num-cell">{{ bet.stake }}</td>
<td class="num-cell">{{ bet.potentialReturn ?? '-' }}</td>
<td>
<span class="desktop-records-status" :class="statusClass(bet.status)">
{{ statusLabel(bet.status) }}
</span>
</td>
<td class="date-cell">{{ bet.placedAt ? new Date(bet.placedAt).toLocaleString() : '-' }}</td>
</tr>
</template>
<tr v-else class="state-row">
<td :colspan="COL_COUNT" class="desktop-records-table-empty">
{{ t('history.empty') }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!initialLoading && !loading && total > 0" class="desktop-records-pagination">
<Pagination
v-model="page"
v-model:pageSize="pageSize"
:total="total"
@change="loadPage"
/>
</div>
</section>
</div>
</template>
<style scoped>
.muted-cell {
color: var(--text-muted);
max-width: 280px;
}
</style>

View File

@@ -0,0 +1,334 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useOutrightEvents } from '../../composables/useOutrightEvents';
import type { OutrightEvent, OutrightSelection } from '../../components/outright/OutrightEventSection.vue';
import OutrightOptionCard from '../../components/outright/OutrightOptionCard.vue';
import OutrightBetModal, { type OutrightPick } from '../../components/outright/OutrightBetModal.vue';
import { useAuthStore } from '../../stores/auth';
import GoldSpinner from '../../components/GoldSpinner.vue';
import saishiImg from '../../assets/images/saishi.webp';
import cardBg from '../../assets/images/card-bg.webp';
import { useDesktopBetLocate, scrollToBetSelection } from '../../composables/useDesktopBetLocate';
const matchCardBg = `url(${cardBg})`;
const ODDS_POLL_MS = 15000;
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const auth = useAuthStore();
const { loading, loadError, events, load } = useOutrightEvents();
const { pendingLocate, clearLocate } = useDesktopBetLocate();
const modalOpen = ref(false);
const activePick = ref<OutrightPick | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const event = computed<OutrightEvent | null>(() =>
events.value.find((e) => e.id === String(route.params.id)) ?? null,
);
const headTitle = computed(() => {
if (!event.value) return '';
const raw = event.value.title.replace(/^\*+/, '').trim();
return raw || event.value.leagueName || t('bet.tab_outright');
});
const teamCount = computed(() =>
event.value ? (event.value.selectionCount ?? event.value.selections.length) : 0,
);
const isSettled = computed(
() => event.value?.bettingOpen === false || event.value?.status === 'SETTLED',
);
function goBack() {
router.push({ path: '/bet', query: { tab: 'outright' } });
}
function openBet(sel: OutrightSelection) {
if (!event.value || isSettled.value) return;
if (!auth.token) {
auth.showLoginPrompt(route.fullPath);
return;
}
activePick.value = {
selectionId: sel.id,
oddsVersion: sel.oddsVersion,
teamCode: sel.teamCode,
teamName: sel.teamName,
odds: sel.odds,
eventTitle: event.value.title,
};
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
activePick.value = null;
}
function startPolling() {
stopPolling();
pollTimer = setInterval(() => {
void load({ silent: true });
}, ODDS_POLL_MS);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
watch(
() => event.value?.id,
(id) => {
if (id) startPolling();
else stopPolling();
},
);
watch(
[pendingLocate, () => event.value?.id, loading],
async ([target, eventId, isLoading]) => {
if (!target || isLoading || !eventId) return;
if (String(eventId) !== String(target.matchId)) return;
const found = await scrollToBetSelection(target.selectionId);
if (found) clearLocate();
},
{ flush: 'post' },
);
onMounted(() => {
if (event.value) startPolling();
});
onUnmounted(stopPolling);
</script>
<template>
<div class="desktop-outright-detail">
<div class="detail-breadcrumbs">
<button type="button" class="back-btn" @click="goBack"> {{ t('bet.back') }}</button>
<span class="sep">/</span>
<span class="curr">{{ t('bet.tab_outright') }}</span>
</div>
<div class="detail-main">
<div v-if="loading && !event" class="loading-state">
<GoldSpinner :active="true" />
</div>
<template v-else-if="event">
<header class="event-hero" :class="{ settled: isSettled }">
<div class="hero-text">
<div class="title-row">
<h1 class="hero-title">{{ headTitle }}</h1>
<span v-if="isSettled" class="settled-tag">{{ t('bet.outright_settled') }}</span>
</div>
<p v-if="event.leagueName && event.leagueName !== headTitle" class="hero-league">
{{ event.leagueName }}
</p>
<p class="hero-meta">{{ t('bet.outright_teams_count', { n: teamCount }) }}</p>
<p v-if="isSettled" class="hero-hint">{{ t('bet.outright_settled_hint') }}</p>
</div>
<img :src="saishiImg" alt="" class="hero-icon" />
</header>
<div class="teams-grid">
<OutrightOptionCard
v-for="sel in event.selections"
:key="sel.id"
:team-code="sel.teamCode"
:team-name="sel.teamName"
:logo-url="sel.logoUrl"
:odds="sel.odds"
:selection-id="sel.id"
:disabled="isSettled"
:is-winner="Boolean(sel.isWinner)"
@pick="openBet(sel)"
/>
</div>
</template>
<div v-else class="empty-state">
<p>{{ loadError || t('bet.no_outright') }}</p>
<button type="button" class="back-link" @click="goBack">{{ t('bet.back') }}</button>
</div>
</div>
<OutrightBetModal :open="modalOpen" :pick="activePick" @close="closeModal" />
</div>
</template>
<style scoped>
.desktop-outright-detail {
display: flex;
flex-direction: column;
min-height: 100%;
}
.detail-breadcrumbs {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
font-size: 12px;
color: var(--text-muted);
background: #0c0c0c;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.detail-main {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 16px 14px;
}
.detail-breadcrumbs .sep {
flex-shrink: 0;
}
.detail-breadcrumbs .curr {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-weight: 700;
}
.back-btn {
flex-shrink: 0;
background: transparent;
color: var(--primary-light);
font-weight: 700;
border: none;
padding: 0;
cursor: pointer;
}
.back-btn:hover {
text-decoration: underline;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 240px;
}
.event-hero {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(18, 18, 18, 0.98);
overflow: hidden;
}
.event-hero::before {
content: '';
position: absolute;
inset: 0;
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
opacity: 0.22;
pointer-events: none;
}
.hero-text {
position: relative;
z-index: 1;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.hero-title {
margin: 0;
font-size: 15px;
font-weight: 800;
color: var(--primary-light);
line-height: 1.35;
}
.settled-tag {
font-size: 10px;
font-weight: 800;
color: #c9a227;
border: 1px solid rgba(201, 162, 39, 0.45);
border-radius: 999px;
padding: 1px 7px;
}
.hero-league {
margin: 4px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.hero-meta {
margin: 4px 0 0;
font-size: 11px;
font-weight: 600;
color: #666;
}
.hero-hint {
margin: 8px 0 0;
font-size: 11px;
font-weight: 600;
color: #c9a227;
line-height: 1.45;
}
.hero-icon {
position: relative;
z-index: 1;
flex-shrink: 0;
height: 48px;
width: auto;
max-width: 44px;
object-fit: contain;
}
.teams-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.empty-state {
text-align: center;
padding: 48px 16px;
color: var(--text-muted);
font-size: 13px;
}
.back-link {
margin-top: 12px;
background: transparent;
color: var(--primary-light);
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,353 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
import api from '../../api';
import PlayerAvatarModal from '../../components/PlayerAvatarModal.vue';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { usePlayerProfile } from '../../composables/usePlayerProfile';
import { useAuthStore } from '../../stores/auth';
const { t } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { loadProfile, setAvatarKey, profileRaw, avatarUrl } = usePlayerProfile();
const username = ref('');
const viewablePassword = ref('');
const passwordVisible = ref(false);
const phone = ref('');
const email = ref('');
const avatarModalOpen = ref(false);
const passwordChangeOpen = ref(false);
const oldPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const message = ref('');
const error = ref('');
const saving = ref(false);
const allowPasswordChange = computed(
() => profileRaw.value?.preferences?.allowPasswordChange ?? true,
);
const allowUsernameChange = computed(
() => profileRaw.value?.preferences?.allowUsernameChange ?? false,
);
const canTogglePassword = computed(() => !!viewablePassword.value);
const passwordInputType = computed(() =>
passwordVisible.value && canTogglePassword.value ? 'text' : 'password',
);
const displayAvatarUrl = computed(() => {
if (avatarUrl.value) return avatarUrl.value;
const seed = profileRaw.value?.username ?? auth.user?.username;
return seed ? playerAvatarUrl(randomAvatarKey(seed)) : null;
});
function syncFromProfile() {
const user = profileRaw.value;
username.value = user?.username ?? auth.user?.username ?? '';
viewablePassword.value = user?.preferences?.viewablePassword ?? '';
phone.value = user?.preferences?.phone ?? '';
email.value = user?.preferences?.email ?? '';
}
async function loadEditData() {
await loadProfile(true);
syncFromProfile();
}
onMounted(loadEditData);
async function saveProfile() {
saving.value = true;
error.value = '';
message.value = '';
try {
await api.patch('/player/profile', { username: username.value, phone: phone.value, email: email.value });
message.value = t('profile.save_success') || '保存成功';
await loadProfile(true);
syncFromProfile();
} catch (e: any) {
error.value = e?.response?.data?.message ?? t('common.error');
} finally {
saving.value = false;
}
}
async function savePassword() {
if (newPassword.value !== confirmPassword.value) {
error.value = t('profile.password_mismatch') || '两次密码不一致';
return;
}
saving.value = true;
error.value = '';
message.value = '';
try {
await api.post('/player/profile/change-password', { oldPassword: oldPassword.value, newPassword: newPassword.value });
message.value = t('profile.password_changed') || '密码修改成功';
passwordChangeOpen.value = false;
oldPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
} catch (e: any) {
error.value = e?.response?.data?.message ?? t('common.error');
} finally {
saving.value = false;
}
}
async function onAvatarSelect(key: string | null) {
avatarModalOpen.value = false;
await setAvatarKey(key);
}
</script>
<template>
<div class="desktop-account-page">
<main class="account-main">
<div class="edit-layout">
<div class="page-header">
<h1 class="page-title">{{ t('profile.edit_title') }}</h1>
</div>
<!-- Avatar section -->
<div class="edit-card">
<div class="card-title">{{ t('profile.avatar') || '头像' }}</div>
<div class="avatar-row">
<img
v-if="displayAvatarUrl"
:src="displayAvatarUrl"
alt="avatar"
class="avatar-img"
/>
<div v-else class="avatar-placeholder">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
</div>
<button type="button" class="change-avatar-btn" @click="avatarModalOpen = true">
{{ t('profile.change_avatar') || '更换头像' }}
</button>
</div>
</div>
<!-- Basic info -->
<div class="edit-card">
<div class="card-title">{{ t('profile.basic_info') || '基础信息' }}</div>
<div v-if="message" class="success-msg">{{ message }}</div>
<div v-if="error" class="error-msg">{{ error }}</div>
<div class="field-group">
<div class="field">
<label class="field-label">{{ t('profile.username') || '用户名' }}</label>
<input
v-model="username"
type="text"
class="field-input"
:disabled="!allowUsernameChange"
/>
<p v-if="!allowUsernameChange" class="field-hint">{{ t('profile.username_locked') || '用户名不可修改' }}</p>
</div>
<div class="field">
<label class="field-label">{{ t('profile.phone') || '手机号' }}</label>
<input v-model="phone" type="text" class="field-input" />
</div>
<div class="field">
<label class="field-label">Email</label>
<input v-model="email" type="email" class="field-input" />
</div>
</div>
<button type="button" class="save-btn" :disabled="saving" @click="saveProfile">
<GoldSpinner v-if="saving" :size="18" />
<span v-else>{{ t('common.save') || '保存' }}</span>
</button>
</div>
<!-- Password change -->
<div v-if="allowPasswordChange" class="edit-card">
<div class="card-title">
{{ t('profile.change_password') || '修改密码' }}
<button
type="button"
class="toggle-btn"
@click="passwordChangeOpen = !passwordChangeOpen"
>
{{ passwordChangeOpen ? (t('common.collapse') || '收起') : (t('common.expand') || '展开') }}
</button>
</div>
<div v-show="passwordChangeOpen" class="field-group">
<div class="field">
<label class="field-label">{{ t('profile.old_password') || '当前密码' }}</label>
<input v-model="oldPassword" type="password" class="field-input" autocomplete="current-password" />
</div>
<div class="field">
<label class="field-label">{{ t('profile.new_password') || '新密码' }}</label>
<input v-model="newPassword" type="password" class="field-input" autocomplete="new-password" />
</div>
<div class="field">
<label class="field-label">{{ t('profile.confirm_password') || '确认密码' }}</label>
<input v-model="confirmPassword" type="password" class="field-input" autocomplete="new-password" />
</div>
<button type="button" class="save-btn" :disabled="saving" @click="savePassword">
<GoldSpinner v-if="saving" :size="18" />
<span v-else>{{ t('profile.change_password') || '修改密码' }}</span>
</button>
</div>
</div>
</div>
<PlayerAvatarModal
:open="avatarModalOpen"
:model-value="profileRaw?.preferences?.avatarKey ?? null"
@close="avatarModalOpen = false"
@confirm="(key) => { avatarModalOpen = false; if (key !== undefined) setAvatarKey(key); }"
/>
</main>
</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; overflow-y: auto; }
.page-header { margin-bottom: 24px; }
.page-title { font-size: 18px; font-weight: 800; color: var(--primary-light); margin: 0; }
.edit-layout {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 560px;
margin: 0 auto;
}
.edit-card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 20px;
}
.card-title {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--desktop-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.avatar-row {
display: flex;
align-items: center;
gap: 16px;
}
.avatar-img {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border-gold-soft);
}
.avatar-placeholder {
width: 64px;
height: 64px;
border-radius: 50%;
background: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
border: 2px solid var(--desktop-border);
}
.change-avatar-btn {
padding: 7px 16px;
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;
}
.change-avatar-btn:hover { border-color: var(--border-gold-soft); color: var(--primary-light); }
.field-group { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.field-input {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--desktop-border);
background: #111;
color: var(--text);
font-size: 14px;
font-weight: 600;
outline: none;
transition: border-color 0.2s;
}
.field-input:focus { border-color: var(--border-gold-soft); }
.field-input:disabled { opacity: 0.45; cursor: not-allowed; }
.field-hint { font-size: 11px; color: var(--text-muted); margin: 0; }
.success-msg { padding: 10px 14px; border-radius: 6px; background: rgba(52,199,89,0.1); color: #4cd964; font-size: 13px; font-weight: 700; margin-bottom: 14px; }
.error-msg { padding: 10px 14px; border-radius: 6px; background: rgba(255,69,58,0.1); color: #ff453a; font-size: 13px; font-weight: 700; margin-bottom: 14px; }
.save-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 24px;
border-radius: 6px;
background: var(--primary);
color: #111;
font-size: 13px;
font-weight: 800;
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.save-btn:hover:not(:disabled) { opacity: 0.88; }
.save-btn:disabled { opacity: 0.45; cursor: default; }
.toggle-btn {
background: none;
border: none;
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
text-transform: none;
letter-spacing: 0;
}
</style>

View File

@@ -0,0 +1,439 @@
<script setup lang="ts">
import { ref, onMounted, onActivated, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import LocaleFlag from '../../components/LocaleFlag.vue';
import { useAuthStore } from '../../stores/auth';
import { useAppLocale } from '../../composables/useAppLocale';
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();
const auth = useAuthStore();
const { locales, setLocale, initFromUser } = useAppLocale();
const { profileRaw, refreshProfile } = usePlayerProfile();
const { hubRoute } = useInboxFeature();
const loading = ref(true);
const error = ref(false);
const cashbackTotal = ref('0');
async function fetchProfile() {
loading.value = true;
error.value = false;
try {
await refreshProfile();
initFromUser(profileRaw.value?.locale);
void fetchCashbackTotal();
} catch {
error.value = true;
} finally {
loading.value = false;
}
}
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 */ }
}
}
onMounted(fetchProfile);
onActivated(fetchProfile);
function logout() {
auth.logout();
router.push('/');
}
const balanceDisplay = computed(() =>
formatMoney(profileRaw.value?.wallet?.availableBalance, locale.value),
);
</script>
<template>
<div class="desktop-account-page">
<main class="account-main">
<div class="profile-layout">
<div class="page-header">
<h1 class="page-title">{{ t('nav.profile') }}</h1>
</div>
<div v-if="loading" class="loading-state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="error" class="error-state">
<p>{{ t('common.load_failed') }}</p>
<button type="button" class="retry-btn" @click="fetchProfile">{{ t('common.retry') }}</button>
</div>
<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">
<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>
<router-link to="/wallet/recharge" class="recharge-btn">
<span class="recharge-icon">+</span>
<span>{{ t('recharge.title') }}</span>
</router-link>
</div>
<div class="bank-card-balance">
<span class="bank-card-label">{{ t('wallet.available') || '可用余额' }}</span>
<p class="bank-card-number">{{ balanceDisplay }}</p>
</div>
<div class="bank-card-footer">
<div class="bank-card-field">
<span class="bank-card-label">{{ t('profile.username') || '持卡人' }}</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('wallet.cashback_total') || '累计返水' }}</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.frozen') || '冻结' }}</span>
<span class="bank-card-stat">{{ formatMoney(profileRaw?.wallet?.frozenBalance, locale) }}</span>
</div>
</div>
</div>
</div>
<!-- Account info -->
<div class="info-card">
<div class="info-header">{{ t('profile.account') || '账户信息' }}</div>
<div class="info-row">
<span class="info-label">{{ t('profile.username') || '用户名' }}</span>
<span class="info-val">{{ profileRaw?.username }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('profile.phone') || '手机号' }}</span>
<span class="info-val">{{ profileRaw?.preferences?.phone || '-' }}</span>
</div>
<div class="info-actions">
<router-link to="/profile/edit" class="edit-btn">{{ t('profile.edit') }}</router-link>
</div>
</div>
<!-- Language -->
<div class="lang-card">
<div class="info-header">{{ t('profile.language') }}</div>
<div class="lang-group">
<button
v-for="item in locales"
:key="item.code"
type="button"
class="lang-opt"
:class="{ active: locale === item.code }"
@click="setLocale(item.code)"
>
<LocaleFlag :locale="item.code" :size="14" />
<span>{{ item.label }}</span>
</button>
</div>
</div>
<!-- Logout -->
<button type="button" class="logout-btn" @click="logout">
{{ t('auth.logout') }}
</button>
</template>
</div>
</main>
</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; overflow-y: auto; }
.page-header { margin-bottom: 24px; }
.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; }
.error-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 80px 20px; color: var(--text-muted); }
.retry-btn { padding: 8px 24px; border-radius: 6px; border: 1px solid var(--primary); background: transparent; color: var(--primary-light); font-size: 13px; font-weight: 700; cursor: pointer; }
.profile-layout {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 600px;
margin: 0 auto;
}
.wallet-banner {
position: relative;
width: 100%;
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%);
}
.card-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
padding: 24px;
}
.bank-card-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.bank-card-top-left {
display: flex;
align-items: center;
gap: 8px;
}
.brand-logo {
width: 24px;
height: 24px;
object-fit: contain;
}
.bank-card-brand-text {
display: flex;
flex-direction: column;
}
.brand-name {
font-size: 13px;
font-weight: 700;
color: #fff;
letter-spacing: 0.14em;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.bank-card-type {
font-size: 10px;
font-weight: 800;
color: rgba(212, 175, 55, 0.8);
font-style: italic;
}
.recharge-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(212, 175, 55, 0.35);
color: var(--primary-light);
font-size: 13px;
font-weight: 600;
text-decoration: none;
backdrop-filter: blur(6px);
transition: background 0.15s ease;
}
.recharge-btn:hover { background: rgba(212, 175, 55, 0.15); }
.bank-card-balance {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
}
.bank-card-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.82);
}
.bank-card-number {
margin: 0;
font-family: 'SF Mono', 'Consolas', monospace;
font-weight: 700;
color: var(--primary-light);
font-size: 38px;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
}
.bank-card-footer {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.bank-card-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.bank-card-field--center { text-align: center; }
.bank-card-field--right { text-align: right; }
.bank-card-holder {
font-size: 14px;
font-weight: 700;
color: #fff;
text-transform: uppercase;
}
.bank-card-stat {
font-size: 14px;
font-weight: 700;
color: #fff;
}
.info-card, .lang-card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 20px;
}
.info-header {
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);
}
.info-row {
display: flex;
gap: 12px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.info-label { width: 80px; flex-shrink: 0; font-size: 12px; color: var(--text-muted); font-weight: 600; }
.info-val { flex: 1; font-size: 14px; font-weight: 700; color: var(--text); }
.info-actions { padding-top: 14px; }
.edit-btn {
display: inline-flex;
align-items: center;
padding: 7px 18px;
border-radius: 6px;
border: 1px solid var(--border-gold-soft);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
text-decoration: none;
transition: background 0.2s;
}
.edit-btn:hover { background: rgba(212,175,55,0.08); }
.lang-group { display: flex; gap: 8px; flex-wrap: wrap; }
.lang-opt {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
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;
}
.lang-opt.active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: rgba(212,175,55,0.08);
}
.logout-btn {
display: block;
width: 100%;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--desktop-border);
background: transparent;
color: var(--danger);
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
}
.logout-btn:hover { background: rgba(255,69,58,0.06); }
</style>

View File

@@ -0,0 +1,145 @@
<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 { formatMoney } from '../../utils/localeDisplay';
const COL_COUNT = 5;
const { t, locale } = useI18n();
type RechargeOrder = {
id: string;
orderNo: string;
amount: string;
status: string;
methodType?: string;
bankName?: string | null;
paymentMethodName?: string | null;
approvedAmount?: string | null;
createdAt: string;
};
const items = ref<RechargeOrder[]>([]);
const loading = ref(true);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
async function loadPage(p: number) {
loading.value = true;
try {
const { data } = await api.get('/player/deposit-orders', { 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(page.value));
function methodLabel(order: RechargeOrder) {
if (order.methodType === 'USDT') return 'USDT';
return order.paymentMethodName || order.bankName || order.methodType || '-';
}
function statusClass(status: string) {
const map: Record<string, string> = {
APPROVED: 'status-won',
REJECTED: 'status-lost',
PENDING: 'status-pending',
};
return map[status?.toUpperCase()] ?? 'status-default';
}
function statusLabel(status: string) {
const s = status?.toUpperCase();
if (s === 'APPROVED') return t('recharge.status_approved');
if (s === 'REJECTED') return t('recharge.status_rejected');
return t('recharge.status_pending');
}
</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>
<section class="desktop-records-panel">
<div class="desktop-records-table-wrap">
<table class="desktop-records-table">
<thead>
<tr>
<th>{{ t('recharge.order_no') }}</th>
<th>{{ t('recharge.method') }}</th>
<th class="num-th">{{ t('wallet.amount') }}</th>
<th>{{ t('common.status') }}</th>
<th>{{ t('wallet.time') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loading" class="state-row">
<td :colspan="COL_COUNT">
<div class="desktop-records-loading-cell">
<GoldSpinner :size="36" />
</div>
</td>
</tr>
<template v-else-if="items.length">
<tr v-for="order in items" :key="order.id" class="hover-row">
<td class="order-no">{{ order.orderNo || order.id }}</td>
<td>{{ methodLabel(order) }}</td>
<td class="num-cell">{{ formatMoney(order.amount, locale) }}</td>
<td>
<span class="desktop-records-status" :class="statusClass(order.status)">
{{ statusLabel(order.status) }}
</span>
</td>
<td class="date-cell">{{ new Date(order.createdAt).toLocaleString() }}</td>
</tr>
</template>
<tr v-else class="state-row">
<td :colspan="COL_COUNT" class="desktop-records-table-empty">
{{ t('recharge.no_orders') }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!loading && total > 0" class="desktop-records-pagination">
<Pagination
v-model="page"
v-model:pageSize="pageSize"
:total="total"
@change="loadPage"
/>
</div>
</section>
</div>
</template>
<style scoped>
.order-no {
font-weight: 700;
color: var(--primary-light);
}
</style>

View File

@@ -0,0 +1,580 @@
<script setup lang="ts">
import { ref, onMounted, onActivated, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import imageCompression from 'browser-image-compression';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { useDepositNotifications } from '../../composables/useDepositNotifications';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const { trackPendingOrder } = useDepositNotifications();
const reapplyOrderId = computed(() => {
const id = route.query.orderId;
return typeof id === 'string' && id ? id : '';
});
const isReapply = computed(() => !!reapplyOrderId.value);
interface PaymentMethod {
id: string;
methodType: string;
bankName: string | null;
accountHolder: string | null;
accountNumber: string | null;
usdtAddress: string | null;
qrCodeUrl: string | null;
displayName: string | null;
}
const methodType = ref<'BANK' | 'USDT'>('BANK');
const methods = ref<PaymentMethod[]>([]);
const selectedMethod = ref<PaymentMethod | null>(null);
const amount = ref<string>('');
const screenshotFile = ref<File | null>(null);
const screenshotPreview = ref<string>('');
const loading = ref(true);
const submitting = ref(false);
const success = ref(false);
const orderNo = ref('');
const compressing = ref(false);
const bankMethods = computed(() => methods.value.filter((m) => m.methodType === 'BANK'));
const usdtMethods = computed(() => methods.value.filter((m) => m.methodType === 'USDT'));
const currentMethods = computed(() => methodType.value === 'BANK' ? bankMethods.value : usdtMethods.value);
function applyReapplyQuery() {
const type = route.query.methodType;
if (type === 'BANK' || type === 'USDT') methodType.value = type;
const methodId = typeof route.query.methodId === 'string' ? route.query.methodId : '';
if (methodId) {
const match = methods.value.find((m) => m.id === methodId);
if (match) selectedMethod.value = match;
}
if (!selectedMethod.value && currentMethods.value.length) selectedMethod.value = currentMethods.value[0];
const amountQuery = typeof route.query.amount === 'string' ? route.query.amount : '';
const parsedAmount = parseFloat(amountQuery);
if (amountQuery && parsedAmount > 0) amount.value = amountQuery;
}
async function fetchMethods() {
loading.value = true;
try {
const { data } = await api.get('/player/payment-methods');
methods.value = (data.data ?? []).map((m: any) => ({ ...m, id: String(m.id) }));
if (isReapply.value) {
applyReapplyQuery();
} else if (currentMethods.value.length) {
selectedMethod.value = currentMethods.value[0];
}
} finally {
loading.value = false;
}
}
function selectMethod(m: PaymentMethod) { selectedMethod.value = m; }
function switchType(type: 'BANK' | 'USDT') {
methodType.value = type;
selectedMethod.value = currentMethods.value[0] ?? null;
}
async function handleFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
compressing.value = true;
const compressed = await imageCompression(file, { maxSizeMB: 0.8, maxWidthOrHeight: 1200 });
screenshotFile.value = compressed;
screenshotPreview.value = URL.createObjectURL(compressed);
} catch {
screenshotFile.value = file;
screenshotPreview.value = URL.createObjectURL(file);
} finally {
compressing.value = false;
}
}
function clearFile() { screenshotFile.value = null; screenshotPreview.value = ''; }
async function submit() {
if (!selectedMethod.value || !amount.value || !screenshotFile.value) return;
submitting.value = true;
try {
const fd = new FormData();
fd.append('paymentMethodId', selectedMethod.value.id);
fd.append('amount', amount.value);
fd.append('screenshot', screenshotFile.value);
if (isReapply.value) fd.append('originalOrderId', reapplyOrderId.value);
const { data } = await api.post('/player/deposits', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
orderNo.value = data.data?.orderNo ?? '';
if (data.data?.id) trackPendingOrder(String(data.data.id));
success.value = true;
} catch {
/* error handled by axios interceptor */
} finally {
submitting.value = false;
}
}
onMounted(fetchMethods);
onActivated(fetchMethods);
</script>
<template>
<div class="desktop-account-page">
<main class="account-main">
<div class="recharge-layout">
<div class="page-header">
<h1 class="page-title">{{ t('recharge.title') }}</h1>
</div>
<div v-if="loading" class="loading-state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="success" class="success-card">
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-5"/></svg>
<h2>{{ t('recharge.success_title') }}</h2>
<p class="order-no">{{ t('recharge.order_no') }}: {{ orderNo }}</p>
<p class="success-hint">{{ t('recharge.success_hint') }}</p>
<button type="button" class="action-btn" @click="router.push('/wallet/recharge/history')">
{{ t('recharge.history_title') }}
</button>
</div>
<template v-else>
<!-- Method type tabs -->
<div class="type-tabs">
<button type="button" class="type-tab" :class="{ active: methodType === 'BANK' }" @click="switchType('BANK')">
{{ t('recharge.bank_transfer') || '银行转账' }}
</button>
<button type="button" class="type-tab" :class="{ active: methodType === 'USDT' }" @click="switchType('USDT')">
USDT
</button>
</div>
<div class="recharge-cols">
<!-- Method list -->
<div class="method-list">
<p class="section-label">{{ t('recharge.choose_method') || '选择收款账户' }}</p>
<div v-if="currentMethods.length === 0" class="no-methods">
{{ t('recharge.no_methods') || '暂无可用收款账户' }}
</div>
<button
v-for="m in currentMethods"
:key="m.id"
type="button"
class="method-card"
:class="{ selected: selectedMethod?.id === m.id }"
@click="selectMethod(m)"
>
<span class="method-name">{{ m.displayName || m.bankName || 'USDT' }}</span>
<span class="method-account">{{ m.accountNumber || m.usdtAddress || '' }}</span>
</button>
</div>
<!-- Form -->
<div v-if="selectedMethod" class="recharge-form">
<!-- Bank details -->
<div v-if="methodType === 'BANK'" class="bank-detail-card">
<div class="detail-row">
<span class="detail-label">{{ t('recharge.bank_name') || '银行名称' }}</span>
<span class="detail-val">{{ selectedMethod.bankName }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('recharge.account_holder') || '持卡人' }}</span>
<span class="detail-val">{{ selectedMethod.accountHolder }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('recharge.account_number') || '账号' }}</span>
<span class="detail-val mono">{{ selectedMethod.accountNumber }}</span>
</div>
</div>
<!-- USDT QR -->
<div v-if="methodType === 'USDT' && selectedMethod.qrCodeUrl" class="qr-wrap">
<img :src="selectedMethod.qrCodeUrl" alt="USDT QR" class="qr-img" />
</div>
<div v-if="methodType === 'USDT'" class="detail-row">
<span class="detail-label">{{ t('recharge.usdt_address') || 'USDT地址' }}</span>
<span class="detail-val mono break-all">{{ selectedMethod.usdtAddress }}</span>
</div>
<!-- Amount input -->
<div class="field">
<label class="field-label">{{ t('recharge.amount') || '充值金额' }}</label>
<input
v-model="amount"
type="number"
min="0"
class="field-input"
:placeholder="t('recharge.amount_placeholder') || '请输入金额'"
/>
</div>
<!-- Screenshot upload -->
<div class="field">
<label class="field-label">{{ t('recharge.screenshot') || '上传凭证截图' }}</label>
<div v-if="screenshotPreview" class="preview-wrap">
<img :src="screenshotPreview" alt="preview" class="preview-img" />
<button type="button" class="clear-btn" @click="clearFile"></button>
</div>
<label v-else class="upload-zone">
<input type="file" accept="image/*" class="hidden-input" @change="handleFileChange" />
<GoldSpinner v-if="compressing" :size="24" />
<template v-else>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.5"><path d="M4 17v1a2 2 0 002 2h12a2 2 0 002-2v-1M12 3v12M8 7l4-4 4 4"/></svg>
<span class="upload-hint">{{ t('recharge.click_to_upload') || '点击上传截图' }}</span>
</template>
</label>
</div>
<button
type="button"
class="submit-btn"
:disabled="submitting || !amount || !screenshotFile"
@click="submit"
>
<GoldSpinner v-if="submitting" :size="18" />
<span v-else>{{ t('recharge.submit') || '提交充值' }}</span>
</button>
</div>
</div>
</template>
</div>
</main>
</div>
</template>
<style scoped>
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
.recharge-layout {
display: flex;
flex-direction: column;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.account-main {
flex: 1;
min-width: 0;
padding: 24px 28px;
overflow-y: auto;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 800;
color: var(--primary-light);
margin: 0;
letter-spacing: 0.02em;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
padding: 80px 0;
}
.success-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 60px 20px;
text-align: center;
}
.success-card h2 {
font-size: 20px;
font-weight: 800;
color: var(--primary-light);
margin: 0;
}
.order-no {
font-size: 13px;
color: var(--text-muted);
margin: 0;
font-weight: 700;
}
.success-hint {
font-size: 13px;
color: var(--text-muted);
margin: 0;
}
.action-btn {
padding: 9px 24px;
border-radius: 6px;
background: var(--primary);
color: #111;
font-size: 13px;
font-weight: 800;
border: none;
cursor: pointer;
transition: opacity 0.2s;
text-decoration: none;
display: inline-block;
}
.action-btn:hover { opacity: 0.88; }
.type-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.type-tab {
padding: 6px 20px;
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;
}
.type-tab.active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: rgba(212,175,55,0.08);
}
.recharge-cols {
display: grid;
grid-template-columns: 240px 1fr;
gap: 24px;
align-items: start;
max-width: 800px;
margin: 0 auto;
}
.section-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 10px;
}
.no-methods {
font-size: 13px;
color: var(--text-muted);
padding: 12px 0;
}
.method-list {
display: flex;
flex-direction: column;
}
.method-card {
display: flex;
flex-direction: column;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid var(--desktop-border);
background: var(--bg-card);
backdrop-filter: blur(12px);
cursor: pointer;
margin-bottom: 8px;
text-align: left;
transition: all 0.2s;
}
.method-card:hover { border-color: rgba(255, 255, 255, 0.15); }
.method-card.selected {
border-color: var(--border-gold-soft);
background: rgba(212,175,55,0.06);
}
.method-name {
font-size: 13px;
font-weight: 800;
color: var(--text);
}
.method-account {
font-size: 11px;
color: var(--text-muted);
}
.recharge-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.bank-detail-card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.detail-row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.detail-label {
width: 80px;
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding-top: 1px;
}
.detail-val {
flex: 1;
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.mono { font-family: 'SF Mono', 'Consolas', monospace; letter-spacing: 0.04em; }
.break-all { word-break: break-all; }
.qr-wrap {
display: flex;
justify-content: center;
padding: 12px 0;
}
.qr-img {
width: 140px;
height: 140px;
object-fit: contain;
border-radius: 8px;
border: 1px solid var(--desktop-border);
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.field-input {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--desktop-border);
background: #111;
color: var(--text);
font-size: 14px;
font-weight: 700;
outline: none;
transition: border-color 0.2s;
max-width: 320px;
}
.field-input:focus { border-color: var(--border-gold-soft); }
.preview-wrap {
position: relative;
display: inline-block;
}
.preview-img {
max-width: 180px;
max-height: 180px;
border-radius: 8px;
border: 1px solid var(--desktop-border);
object-fit: cover;
display: block;
}
.clear-btn {
position: absolute;
top: -8px;
right: -8px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #333;
border: none;
color: #fff;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 180px;
height: 120px;
border: 2px dashed var(--desktop-border);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
}
.upload-zone:hover { border-color: var(--border-gold-soft); }
.hidden-input { display: none; }
.upload-hint {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.submit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 32px;
border-radius: 6px;
background: var(--primary);
color: #111;
font-size: 14px;
font-weight: 800;
border: none;
cursor: pointer;
transition: opacity 0.2s;
align-self: flex-start;
}
.submit-btn:hover:not(:disabled) { opacity: 0.88; }
.submit-btn:disabled { opacity: 0.45; cursor: default; }
</style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
/**
* Desktop Register View
* Reuses the existing RegisterView form in a centered desktop auth layout.
*/
import RegisterView from '../RegisterView.vue';
</script>
<template>
<div class="desktop-auth-shell">
<div class="auth-brand">
<div class="brand-inner">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<h1 class="brand-name">THEBET365</h1>
<p class="brand-tagline">加入我们开始精彩体育投注</p>
</div>
</div>
<div class="auth-card-wrap">
<div class="auth-card">
<RegisterView />
</div>
</div>
</div>
</template>
<style scoped>
.desktop-auth-shell {
display: flex;
min-height: 100vh;
background: var(--desktop-bg);
}
.auth-brand {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
border-right: 1px solid var(--desktop-border);
position: relative;
overflow: hidden;
}
.auth-brand::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(212,175,55,0.06) 0%, transparent 70%);
pointer-events: none;
}
.brand-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
position: relative;
z-index: 1;
padding: 40px;
}
.brand-logo {
width: 64px;
height: 64px;
object-fit: contain;
filter: drop-shadow(0 4px 16px rgba(212,175,55,0.3));
}
.brand-name {
font-size: 28px;
font-weight: 900;
letter-spacing: 0.12em;
color: var(--primary-light);
margin: 0;
}
.brand-tagline {
font-size: 14px;
color: var(--text-muted);
margin: 0;
font-weight: 600;
}
.auth-card-wrap {
width: 520px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: var(--desktop-bg);
overflow-y: auto;
}
.auth-card {
width: 100%;
max-width: 420px;
}
</style>

View File

@@ -0,0 +1,193 @@
<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 { 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;
};
const items = ref<Transaction[]>([]);
const loading = ref(true);
const page = ref(1);
const hasMore = ref(true);
function txLabel(tx: Transaction): string {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
if (translated !== key) return translated;
}
return tx.transactionType;
}
async function loadPage(p: number) {
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);
page.value = p;
} finally {
loading.value = false;
}
}
onMounted(() => loadPage(1));
onActivated(() => loadPage(1));
</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 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>
</div>
</main>
</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;
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-gold-soft); 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; }
</style>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { formatMoney } from '../../utils/localeDisplay';
import { txTypeKey, txDisplayType, txSummaryLabel } from '../../utils/walletTx';
const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
function goBack() {
if (window.history.state && window.history.state.back) {
router.back();
} else {
router.push('/wallet');
}
}
type TxDetail = {
transactionId: string;
transactionType: string;
displayType?: string;
summary?: string | null;
amount: string;
balanceBefore?: string;
balanceAfter?: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string;
referenceType?: string | null;
[key: string]: any;
};
const detail = ref<TxDetail | null>(null);
const loading = ref(true);
const error = ref(false);
async function loadDetail() {
const id = route.params.transactionId as string;
if (!id) return;
loading.value = true;
error.value = false;
try {
const { data } = await api.get(`/player/wallet/transactions/${id}`);
detail.value = data.data ?? null;
} catch {
error.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadDetail);
function txLabel(tx: TxDetail): string {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
if (translated !== key) return translated;
}
return tx.transactionType;
}
</script>
<template>
<div class="desktop-account-page">
<main class="account-main">
<div class="detail-layout">
<div class="page-header">
<button type="button" class="back-btn" @click="goBack">
{{ t('common.back') || '返回' }}
</button>
<h1 class="page-title">{{ t('wallet.transaction_detail') || '交易详情' }}</h1>
</div>
<div v-if="loading" class="loading-state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="error" class="error-state">
<p>{{ t('common.load_failed') }}</p>
<button type="button" class="retry-btn" @click="loadDetail">{{ t('common.retry') }}</button>
</div>
<div v-else-if="detail" class="detail-card">
<div class="amount-hero" :class="parseFloat(detail.amount) >= 0 ? 'pos' : 'neg'">
{{ formatMoney(detail.amount, locale) }}
</div>
<div class="type-label">{{ txLabel(detail) }}</div>
<div class="detail-rows">
<div class="detail-row">
<span class="row-label">{{ t('wallet.type') || '类型' }}</span>
<span class="row-val">{{ txLabel(detail) }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('wallet.note') || '备注' }}</span>
<span class="row-val">{{ txSummaryLabel(detail, t) || '-' }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('wallet.balance_before') || '变动前余额' }}</span>
<span class="row-val mono">{{ formatMoney(detail.balanceBefore, locale) }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('wallet.balance_after') || '变动后余额' }}</span>
<span class="row-val mono">{{ formatMoney(detail.balanceAfter, locale) }}</span>
</div>
<div v-if="parseFloat(detail.frozenBefore || '0') > 0 || parseFloat(detail.frozenAfter || '0') > 0" class="detail-row">
<span class="row-label">{{ t('wallet.frozen_before') || '冻结前' }}</span>
<span class="row-val mono">{{ formatMoney(detail.frozenBefore, locale) }}</span>
</div>
<div v-if="parseFloat(detail.frozenBefore || '0') > 0 || parseFloat(detail.frozenAfter || '0') > 0" class="detail-row">
<span class="row-label">{{ t('wallet.frozen_after') || '冻结后' }}</span>
<span class="row-val mono">{{ formatMoney(detail.frozenAfter, locale) }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('wallet.time') || '时间' }}</span>
<span class="row-val">{{ new Date(detail.createdAt).toLocaleString() }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('wallet.transaction_id') || '流水号' }}</span>
<span class="row-val mono">{{ detail.transactionId }}</span>
</div>
</div>
</div>
<div v-else class="empty-state">{{ t('common.not_found') || '未找到记录' }}</div>
</div>
</main>
</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; overflow-y: auto; }
.detail-layout {
display: flex;
flex-direction: column;
max-width: 560px;
margin: 0 auto;
}
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.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; }
.error-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 80px 20px; color: var(--text-muted); }
.retry-btn { padding: 8px 24px; border-radius: 6px; border: 1px solid var(--primary); background: transparent; color: var(--primary-light); font-size: 13px; font-weight: 700; cursor: pointer; }
.empty-state { display: flex; align-items: center; justify-content: center; padding: 80px 20px; color: var(--text-muted); font-size: 14px; font-weight: 600; }
.detail-card {
background: var(--desktop-sidebar-bg);
border: 1px solid var(--desktop-border);
border-radius: 12px;
padding: 28px;
}
.amount-hero {
font-size: 36px;
font-weight: 800;
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Consolas', monospace;
line-height: 1.1;
margin-bottom: 6px;
}
.amount-hero.pos { color: var(--primary-light); }
.amount-hero.neg { color: var(--danger); }
.type-label {
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid var(--desktop-border);
}
.detail-rows { display: flex; flex-direction: column; gap: 0; }
.detail-row {
display: flex;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.detail-row:last-child { border-bottom: none; }
.row-label { width: 120px; flex-shrink: 0; font-size: 12px; color: var(--text-muted); font-weight: 600; }
.row-val { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); word-break: break-all; }
.row-val.pos { color: var(--primary-light); }
.row-val.neg { color: var(--danger); }
.mono { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; letter-spacing: 0.03em; }
</style>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } 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 { formatMoney } from '../../utils/localeDisplay';
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
const { t, locale } = useI18n();
const router = useRouter();
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;
}
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) {
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 */
} finally {
loading.value = false;
}
}
onMounted(() => fetchData(1));
onActivated(() => fetchData(1));
</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>
<section v-if="loading && !items.length" class="desktop-records-panel">
<div class="desktop-records-loading">
<GoldSpinner :size="36" />
</div>
</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"
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>
<section v-else class="desktop-records-panel">
<div class="desktop-records-empty">
<span>{{ t('wallet.no_records') }}</span>
</div>
</section>
</div>
</template>
<style scoped>
.type-cell {
font-weight: 700;
}
.muted-cell {
color: var(--text-muted);
max-width: 420px;
}
</style>