feat(player): theme-3 移动端/PC 双端拆分与桌面投注工作台

新增 DesktopShell 及桌面端首页、赛事、钱包、记录等页面,原 H5 视图迁移至 Mobile* 并由路由 wrapper 按视口切换。补齐右侧投注单栏、赔率快捷浮层、联赛/赛事侧栏、串关与定位能力;桌面下注成功改为全局 Toast,修复侧栏成功遮罩被裁剪与底部汇总黑底。同步悬浮客服、公告卡片、三语 i18n、桌面样式体系,以及 API 赛事与 shared 包相关调整。
This commit is contained in:
mars
2026-07-07 17:07:38 +08:00
parent fee34feee8
commit e7e2b77906
125 changed files with 26393 additions and 8687 deletions

View File

@@ -1,752 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onActivated, computed } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import LocaleFlag from '../components/LocaleFlag.vue';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
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 loading = ref(true);
const error = ref(false);
const rulesExpanded = ref(false);
const cashbackTotal = ref('0');
const displayAmount = ref(0);
const animating = ref(false);
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 {
// Fallback: sum wallet cashback credits when batch detail API is unavailable.
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 {
// Keep default value
}
}
}
onMounted(() => {
void fetchProfile();
});
onActivated(() => {
void fetchProfile();
});
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await fetchProfile(); },
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
async function changeLocale(code: string) {
await setLocale(code);
}
function logout() {
auth.logout();
router.push('/');
}
const balanceDisplay = computed(() =>
formatMoney(profileRaw.value?.wallet?.availableBalance, locale.value),
);
const balanceAmountClass = computed(() => {
const len = balanceDisplay.value.length;
if (len <= 10) return 'balance-amount--xl';
if (len <= 13) return 'balance-amount--lg';
if (len <= 16) return 'balance-amount--md';
if (len <= 19) return 'balance-amount--sm';
return 'balance-amount--xs';
});
import { useViewport } from '../composables/useViewport';
import MobileProfileView from './MobileProfileView.vue';
import DesktopProfileView from './desktop/DesktopProfileView.vue';
const { isDesktop } = useViewport();
</script>
<template>
<div class="profile-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div v-if="loading" class="loading-state">
<GoldSpinner :size="36" :active="true" />
</div>
<div v-else-if="error" class="error-state">
<p class="error-text">{{ t('common.load_failed') }}</p>
<button type="button" class="retry-btn" @click="fetchProfile">{{ t('common.retry') }}</button>
</div>
<template v-else>
<div class="wallet-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>
<RouterLink to="/wallet/recharge" class="recharge-btn">
<span class="recharge-icon">+</span>
<span>{{ t('recharge.title') }}</span>
</RouterLink>
</div>
<div class="bank-card-balance">
<span class="bank-card-label">{{ t('wallet.balance') }}</span>
<p class="bank-card-number balance-amount" :class="balanceAmountClass">{{ balanceDisplay }}</p>
</div>
<div class="bank-card-footer">
<div class="bank-card-field">
<span class="bank-card-label">{{ t('wallet.card_holder') }}</span>
<span class="bank-card-holder">{{ profileRaw?.username }}</span>
</div>
<div class="bank-card-field bank-card-field--center">
<span class="bank-card-label">{{ t('cashback.total_received') }}</span>
<span class="bank-card-stat">{{ formatMoney(cashbackTotal, locale) }}</span>
</div>
<div class="bank-card-field bank-card-field--right">
<span class="bank-card-label">{{ t('wallet.unsettled') }}</span>
<span class="bank-card-stat">{{ formatMoney(profileRaw?.wallet?.frozenBalance, locale) }}</span>
</div>
</div>
</div>
</div>
<div class="quick-actions">
<RouterLink to="/wallet/detail" class="qa-item">
<span class="qa-icon qa-icon--wallet">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24"><path d="M21 12V7H5a2 2 0 010-4h14v4"/><path d="M3 5v14a2 2 0 002 2h16v-5"/><path d="M18 12a2 2 0 100 4 2 2 0 000-4z"/></svg>
</span>
<span class="qa-label">{{ t('wallet.view_all') }}</span>
</RouterLink>
<RouterLink to="/wallet/recharge" class="qa-item">
<span class="qa-icon qa-icon--recharge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
</span>
<span class="qa-label">{{ t('recharge.title') }}</span>
</RouterLink>
<RouterLink to="/profile/cashbacks" class="qa-item">
<span class="qa-icon qa-icon--cashback">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24"><circle cx="12" cy="12" r="8"/><path d="M12 8v4l2.5 2.5"/></svg>
</span>
<span class="qa-label">{{ t('wallet.view_cashbacks') }}</span>
</RouterLink>
<RouterLink to="/wallet/recharge/history" class="qa-item">
<span class="qa-icon qa-icon--history">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M9 13h6M9 17h4"/></svg>
</span>
<span class="qa-label">{{ t('recharge.history_title') }}</span>
</RouterLink>
</div>
<section class="settings-group">
<RouterLink to="/profile/edit" class="settings-cell">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6" />
</svg>
<span class="cell-label">{{ t('profile.edit') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<div class="rules-cell">
<button
type="button"
class="settings-cell rules-toggle"
:aria-expanded="rulesExpanded"
@click="rulesExpanded = !rulesExpanded"
>
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M7 4h10v16H7z" />
<path d="M10 8h6M10 12h6M10 16h4" />
</svg>
<span class="cell-label">{{ t('profile.rules_title') }}</span>
</span>
<span class="cell-chevron" :class="{ open: rulesExpanded }" aria-hidden="true"></span>
</button>
<div v-show="rulesExpanded" class="rules-body">
<p>{{ t('profile.rules_p1') }}</p>
<p>{{ t('profile.rules_p2') }}</p>
<p>{{ t('profile.rules_p3') }}</p>
<p>{{ t('profile.rules_p4') }}</p>
<p>{{ t('profile.rules_p5') }}</p>
</div>
</div>
<div class="settings-cell settings-cell--stack">
<div class="cell-head">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c2.5 2.8 4 6 4 9s-1.5 6.2-4 9M12 3c-2.5 2.8-4 6-4 9s1.5 6.2 4 9" />
</svg>
<span class="cell-label">{{ t('profile.language') }}</span>
</div>
<div class="lang-segment" role="group" :aria-label="t('profile.language')">
<button
v-for="item in locales"
:key="item.code"
type="button"
class="lang-opt"
:class="{ active: locale === item.code }"
@click="changeLocale(item.code)"
>
<LocaleFlag :locale="item.code" :size="14" />
<span>{{ item.label }}</span>
</button>
</div>
</div>
</section>
<button type="button" class="logout-btn" @click="logout">
{{ t('auth.logout') }}
</button>
</template>
</div>
<DesktopProfileView v-if="isDesktop" />
<MobileProfileView v-else />
</template>
<style scoped>
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.profile-page {
padding: 6px 0 10px;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 60vh;
}
.error-text {
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.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;
}
.retry-btn:active {
background: rgba(244, 162, 97, 0.15);
}
.wallet-banner {
position: relative;
width: 100%;
margin-bottom: 10px;
aspect-ratio: 1.75 / 1;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow);
}
.wallet-banner::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
pointer-events: none;
}
.wallet-banner-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
object-position: center center;
pointer-events: none;
user-select: none;
}
.wallet-banner-scrim {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background: linear-gradient(
135deg,
#0F2027 0%,
#F4A261 50%,
#203A43 100%
);
}
.card-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
padding: 10px 14px 10px;
line-height: 1.3;
-webkit-font-smoothing: antialiased;
}
.bank-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-shrink: 0;
}
.bank-card-top-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.brand-logo {
width: 18px;
height: 18px;
flex-shrink: 0;
object-fit: contain;
}
.bank-card-brand-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.brand-name {
font-size: 11px;
font-weight: 700;
color: #FFFFFF;
letter-spacing: 0.14em;
line-height: 1;
}
.bank-card-type {
font-size: 8px;
font-weight: 800;
letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.6);
font-style: italic;
line-height: 1;
}
.bank-card-label {
display: block;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.75);
line-height: 1.3;
}
.recharge-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 5px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #FFFFFF;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1;
text-decoration: none;
transition: background 0.15s ease;
}
.recharge-btn:active {
background: rgba(255, 255, 255, 0.25);
}
.recharge-icon {
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.bank-card-balance {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 0;
padding: 1px 0;
gap: 3px;
}
.bank-card-number {
margin: 0;
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
font-weight: 700;
font-style: normal;
font-variant-numeric: tabular-nums;
color: #FFFFFF;
line-height: 1.1;
letter-spacing: 0.04em;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.balance-amount {
margin: 0;
}
.balance-amount--xl { font-size: clamp(22px, 5.6vw, 27px); }
.balance-amount--lg { font-size: clamp(19px, 5vw, 24px); }
.balance-amount--md { font-size: clamp(16px, 4.4vw, 20px); }
.balance-amount--sm { font-size: clamp(14px, 3.8vw, 17px); }
.balance-amount--xs { font-size: clamp(12px, 3.2vw, 14px); }
.bank-card-footer {
flex-shrink: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.bank-card-field {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.bank-card-field--center { text-align: center; }
.bank-card-field--right { text-align: right; }
.bank-card-holder {
font-size: 12px;
font-weight: 700;
color: #FFFFFF;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.2;
word-break: break-all;
}
.bank-card-stat {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #FFFFFF;
line-height: 1.2;
}
.quick-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-areas:
"a a b b"
"c c d d";
gap: 8px;
margin-bottom: 10px;
}
.qa-item:nth-child(1) { grid-area: a; }
.qa-item:nth-child(2) { grid-area: b; }
.qa-item:nth-child(3) { grid-area: c; }
.qa-item:nth-child(4) { grid-area: d; }
.qa-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 12px 10px;
border-radius: var(--radius-lg);
text-decoration: none;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
min-width: 0;
overflow: hidden;
}
.qa-item:active {
transform: scale(0.96);
box-shadow: none;
}
.qa-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.qa-icon--wallet {
background: rgba(244, 162, 97, 0.15);
color: var(--primary);
}
.qa-icon--recharge {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
}
.qa-icon--cashback {
background: rgba(255, 179, 0, 0.15);
color: var(--warning);
}
.qa-icon--history {
background: rgba(244, 162, 97, 0.15);
color: #F4A261;
}
.qa-icon--inbox {
background: rgba(14, 165, 233, 0.15);
color: #0EA5E9;
}
.qa-label {
font-size: 13px;
font-weight: 600;
color: var(--text);
text-align: left;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.settings-group {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.settings-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 0 14px;
background: none;
color: var(--text);
font-size: 13px;
font-weight: 600;
text-decoration: none;
border-bottom: 1px solid var(--border);
}
.settings-cell:last-child {
border-bottom: none;
}
.settings-cell:active {
background: var(--bg-hover);
}
.settings-cell--stack {
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 8px;
padding: 10px 14px 12px;
min-height: auto;
}
.cell-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.cell-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-muted);
}
.cell-head {
display: flex;
align-items: center;
gap: 10px;
}
.cell-label {
color: var(--text);
}
.cell-chevron {
color: var(--text-muted);
font-size: 20px;
line-height: 1;
font-weight: 300;
transition: transform 0.15s ease;
}
.cell-chevron.open {
transform: rotate(90deg);
}
.rules-toggle {
border: none;
cursor: pointer;
font-family: inherit;
}
.rules-cell .rules-body {
padding: 0 14px 12px;
border-bottom: 1px solid var(--border);
}
.lang-segment {
display: flex;
gap: 6px;
}
.lang-opt {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 30px;
min-width: 0;
padding: 0 5px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lang-opt.active {
border-color: var(--primary);
color: var(--primary);
background: rgba(244, 162, 97, 0.12);
}
.logout-btn {
width: 100%;
margin-top: 10px;
min-height: 40px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--danger);
background: var(--bg-card);
color: var(--danger);
font-size: 13px;
font-weight: 700;
}
.logout-btn:active {
background: rgba(239, 68, 68, 0.12);
}
.rules-body {
font-size: 12px;
line-height: 1.55;
color: var(--text-muted);
}
.rules-body p {
margin: 0 0 8px;
}
.rules-body p:last-child {
margin-bottom: 0;
}
</style>