feat(player): 新增桌面端 UI 与响应式双端路由架构
- 将原移动端页面重命名为 Mobile*,新增 22 个 Desktop* 视图与 DesktopShell 布局体系 - 路由入口改为 Wrapper 视图,通过 useViewport(≥1024px)自动切换移动/桌面端 - 新增投注单面板、盘口弹层、联赛侧栏、数据表格等桌面组件及独立样式令牌 - 扩展 betSlip store、串关筛选、冠军盘、Toast/悬浮信箱等交互与三语 i18n - 公告/消息 Hub 拆分移动与桌面实现;API 投注/钱包/充值记录支持 pageSize 分页
This commit is contained in:
@@ -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>
|
||||
327
apps/player/src/views/desktop/DesktopAnnouncementListView.vue
Normal file
327
apps/player/src/views/desktop/DesktopAnnouncementListView.vue
Normal 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>
|
||||
@@ -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>
|
||||
189
apps/player/src/views/desktop/DesktopBetDetailView.vue
Normal file
189
apps/player/src/views/desktop/DesktopBetDetailView.vue
Normal 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>
|
||||
183
apps/player/src/views/desktop/DesktopCashbackRecordsView.vue
Normal file
183
apps/player/src/views/desktop/DesktopCashbackRecordsView.vue
Normal 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>
|
||||
372
apps/player/src/views/desktop/DesktopFootballView.vue
Normal file
372
apps/player/src/views/desktop/DesktopFootballView.vue
Normal 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>
|
||||
99
apps/player/src/views/desktop/DesktopForgotPasswordView.vue
Normal file
99
apps/player/src/views/desktop/DesktopForgotPasswordView.vue
Normal 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>
|
||||
355
apps/player/src/views/desktop/DesktopHomeView.vue
Normal file
355
apps/player/src/views/desktop/DesktopHomeView.vue
Normal 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>
|
||||
264
apps/player/src/views/desktop/DesktopInboxHubView.vue
Normal file
264
apps/player/src/views/desktop/DesktopInboxHubView.vue
Normal 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>
|
||||
134
apps/player/src/views/desktop/DesktopLoginView.vue
Normal file
134
apps/player/src/views/desktop/DesktopLoginView.vue
Normal 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>
|
||||
771
apps/player/src/views/desktop/DesktopMatchDetailView.vue
Normal file
771
apps/player/src/views/desktop/DesktopMatchDetailView.vue
Normal 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>
|
||||
182
apps/player/src/views/desktop/DesktopMessagesHubShell.vue
Normal file
182
apps/player/src/views/desktop/DesktopMessagesHubShell.vue
Normal 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>
|
||||
194
apps/player/src/views/desktop/DesktopMyBetsView.vue
Normal file
194
apps/player/src/views/desktop/DesktopMyBetsView.vue
Normal 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>
|
||||
334
apps/player/src/views/desktop/DesktopOutrightDetailView.vue
Normal file
334
apps/player/src/views/desktop/DesktopOutrightDetailView.vue
Normal 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>
|
||||
353
apps/player/src/views/desktop/DesktopProfileEditView.vue
Normal file
353
apps/player/src/views/desktop/DesktopProfileEditView.vue
Normal 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>
|
||||
439
apps/player/src/views/desktop/DesktopProfileView.vue
Normal file
439
apps/player/src/views/desktop/DesktopProfileView.vue
Normal 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>
|
||||
145
apps/player/src/views/desktop/DesktopRechargeHistoryView.vue
Normal file
145
apps/player/src/views/desktop/DesktopRechargeHistoryView.vue
Normal 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>
|
||||
580
apps/player/src/views/desktop/DesktopRechargeView.vue
Normal file
580
apps/player/src/views/desktop/DesktopRechargeView.vue
Normal 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>
|
||||
100
apps/player/src/views/desktop/DesktopRegisterView.vue
Normal file
100
apps/player/src/views/desktop/DesktopRegisterView.vue
Normal 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>
|
||||
193
apps/player/src/views/desktop/DesktopWalletDetailView.vue
Normal file
193
apps/player/src/views/desktop/DesktopWalletDetailView.vue
Normal 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>
|
||||
@@ -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>
|
||||
155
apps/player/src/views/desktop/DesktopWalletView.vue
Normal file
155
apps/player/src/views/desktop/DesktopWalletView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user