Files
thebet365/apps/player/src/views/ProfileView.vue
Mars d5e7c8edb3 feat: add smoke tests, agent credit ledger, and player cashback page
Introduce admin smoke-test suite with API probes, agent credit transaction history, and player cashback records; fix SmokeTestModule DI and polish admin/player UI assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 16:05:48 +08:00

495 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, 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 GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import walletBg from '../assets/images/wallet-bg.png';
const { t, locale } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { locales, setLocale, initFromUser } = useAppLocale();
const profile = ref<{
username?: string;
wallet?: { availableBalance: string; frozenBalance: string };
} | null>(null);
const rulesExpanded = ref(false);
const displayAmount = ref(0);
const animating = ref(false);
function amountValue(value: unknown): number {
if (value == null) return 0;
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function runCountUp(target: number) {
const duration = 3000;
const start = performance.now();
animating.value = true;
function step(now: number) {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 5);
displayAmount.value = target * eased;
if (progress < 1) {
requestAnimationFrame(step);
} else {
displayAmount.value = target;
animating.value = false;
}
}
requestAnimationFrame(step);
}
const displayedBalance = computed(() =>
animating.value
? formatMoney(displayAmount.value, locale.value)
: formatMoney(profile.value?.wallet?.availableBalance, locale.value),
);
async function fetchProfile() {
const { data } = await api.get('/player/profile');
profile.value = data.data;
initFromUser(data.data?.locale);
runCountUp(amountValue(data.data?.wallet?.availableBalance));
}
onMounted(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('/login');
}
</script>
<template>
<div class="profile-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div class="wallet-banner" @click="router.push('/wallet/detail')">
<img class="wallet-banner-img" :src="walletBg" alt="" />
<img src="/logo.png" alt="TheBet365" class="wallet-card-logo" />
<div class="wallet-banner-info">
<div class="card-balance-block">
<span class="card-kicker">
<LocaleFlag :locale="locale" :size="14" />
{{ t('wallet.balance') }}
</span>
<p class="card-balance">{{ displayedBalance }}</p>
</div>
<div class="card-foot">
<div class="card-holder">
<span class="card-kicker">{{ t('wallet.card_holder') }}</span>
<span class="card-name">{{ profile?.username }}</span>
</div>
<div class="card-pending">
<span class="card-kicker">{{ t('wallet.unsettled') }}</span>
<span class="card-pending-val">{{ formatMoney(profile?.wallet?.frozenBalance, locale) }}</span>
</div>
</div>
</div>
</div>
<section class="settings-group">
<RouterLink to="/wallet/detail" class="settings-cell settings-cell--gold-entry">
<span class="cell-label">{{ t('wallet.view_all') }}</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/profile/cashbacks" class="settings-cell settings-cell--gold-entry">
<span class="cell-label">{{ t('wallet.view_cashbacks') }}</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/profile/edit" class="settings-cell">
<span class="cell-label">{{ t('profile.edit') }}</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-label">{{ t('profile.rules_title') }}</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">
<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>
</div>
</template>
<style scoped>
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.profile-page {
padding: 8px 0 12px;
}
.wallet-banner {
position: relative;
margin-bottom: 12px;
margin-left: -5px;
margin-right: -5px;
line-height: 0;
cursor: pointer;
box-sizing: border-box;
}
.wallet-banner:active .wallet-banner-img {
filter: brightness(0.9);
transition: filter 0.1s;
}
.wallet-banner-img {
width: 100%;
height: auto;
display: block;
}
.wallet-card-logo {
position: absolute;
top: 9%;
right: 5.5%;
z-index: 2;
width: clamp(42px, 12.8vw, 64px);
height: auto;
object-fit: contain;
pointer-events: none;
filter:
drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45))
drop-shadow(0 0 5px rgba(212, 175, 55, 0.32))
drop-shadow(0 0 10px rgba(240, 216, 117, 0.18));
}
.wallet-banner-info {
position: absolute;
inset: 11% 12% 9% 19%;
display: flex;
flex-direction: column;
gap: clamp(14px, 3.5vw, 20px);
line-height: normal;
box-sizing: border-box;
pointer-events: none;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei UI', 'Helvetica Neue', Arial, sans-serif;
font-stretch: semi-expanded;
}
.card-kicker {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: clamp(10px, 2.5vw, 11px);
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(180, 180, 185, 0.92);
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.75),
0 2px 5px rgba(0, 0, 0, 0.35);
}
.card-balance-block {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: clamp(3px, 1vw, 6px);
min-height: clamp(44px, 12vw, 60px);
}
.card-balance {
margin: 0;
width: 100%;
max-width: 100%;
font-size: clamp(22px, 6.8vw, 36px);
font-weight: 900;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
line-height: 1.05;
white-space: nowrap;
background: var(--gradient-gold);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter:
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.85))
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.45))
drop-shadow(0 0 10px rgba(212, 175, 55, 0.22));
}
.card-foot {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-top: auto;
padding-top: 2px;
}
.card-holder,
.card-pending {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.card-pending {
align-items: flex-end;
text-align: right;
}
.card-name {
font-size: clamp(12px, 3.2vw, 15px);
font-weight: 900;
letter-spacing: 0.06em;
text-transform: uppercase;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 44vw;
background: linear-gradient(180deg, #fff6d8 0%, #e8c96a 38%, #c9a227 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter:
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 2px 5px rgba(0, 0, 0, 0.4));
}
.card-pending-val {
font-size: clamp(12px, 3.2vw, 14px);
font-weight: 900;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
color: rgba(210, 210, 215, 0.95);
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.75),
0 2px 5px rgba(0, 0, 0, 0.35);
white-space: nowrap;
}
.settings-group {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.settings-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 48px;
padding: 0 16px;
background: none;
color: var(--text);
font-size: 14px;
font-weight: 600;
text-decoration: none;
border-bottom: 1px solid var(--border);
}
.settings-cell:last-child {
border-bottom: none;
}
.settings-cell:active {
background: rgba(255, 255, 255, 0.03);
}
.settings-cell--gold-entry {
position: relative;
min-height: 50px;
border-color: rgba(212, 175, 55, 0.18);
border-bottom-color: rgba(212, 175, 55, 0.18);
background:
linear-gradient(90deg, rgba(212, 175, 55, 0.08), rgba(20, 20, 20, 0.65) 46%, rgba(212, 175, 55, 0.04));
box-shadow:
inset 1px 0 0 rgba(212, 175, 55, 0.22),
inset 0 1px 0 rgba(255, 244, 200, 0.05);
}
.settings-cell--gold-entry:active {
background:
linear-gradient(90deg, rgba(212, 175, 55, 0.12), rgba(20, 20, 20, 0.74) 46%, rgba(212, 175, 55, 0.06));
}
.settings-cell--gold-entry .cell-label {
color: #f4dc8b;
font-weight: 800;
}
.settings-cell--gold-entry .cell-chevron {
color: rgba(240, 216, 117, 0.78);
}
.settings-cell--stack {
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 10px;
padding: 12px 16px 14px;
min-height: auto;
}
.cell-head {
display: flex;
align-items: center;
}
.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 16px 14px;
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: 34px;
padding: 0 6px;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
}
.lang-opt.active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
}
.logout-btn {
width: 100%;
margin-top: 12px;
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--danger);
font-size: 14px;
font-weight: 700;
}
.logout-btn:active {
background: rgba(255, 69, 58, 0.08);
}
.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>