feat(player): 注册账号、登录双模式与移动端性能优化

注册必填 7-32 位账号,手机号区号/本地号分存;登录默认账号模式并支持切换手机号登录;Player i18n 拆包与赛事接口优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 10:56:51 +08:00
parent 83f0f380c5
commit 312c3c5816
35 changed files with 1944 additions and 1394 deletions

View File

@@ -2,8 +2,8 @@
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { useBetSlipStore } from '../stores/betSlip';
import { usePlayerMatches } from '../composables/usePlayerMatches';
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
import OutrightPanel from '../components/outright/OutrightPanel.vue';
import ParlayPanel from '../components/parlay/ParlayPanel.vue';
@@ -55,21 +55,16 @@ const slip = useBetSlipStore();
const mainTab = ref<MainTab>('matches');
const timeTab = ref<TimeTab>('early');
const showAll = ref(false);
const matches = ref<Match[]>([]);
const loading = ref(true);
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
const matches = summaryMatches;
const loading = summaryLoading;
const expandedLeagues = ref<Set<string>>(new Set());
async function loadMatches() {
loading.value = true;
try {
const { data } = await api.get('/player/matches');
matches.value = data.data ?? [];
} finally {
loading.value = false;
}
await loadSummary(true);
}
useOnLocaleChange(loadMatches);
useOnLocaleChange(() => loadSummary(true));
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await loadMatches(); },
@@ -141,6 +136,10 @@ watch(leagueGroups, (groups) => {
if (!groups.some((g) => g.leagueId === id)) ids.delete(id);
}
if (ids.size !== expandedLeagues.value.size) expandedLeagues.value = ids;
// 默认只展开第一个联赛,减少首屏 DOM
if (groups.length > 0 && expandedLeagues.value.size === 0) {
expandedLeagues.value = new Set([groups[0].leagueId]);
}
});
function isLeagueExpanded(leagueId: string) {

View File

@@ -54,9 +54,10 @@ function formatKickoff(startTime: string) {
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
<div
v-for="match in hotMatches"
v-for="(match, index) in hotMatches"
:key="match.id"
class="match-card"
:class="{ 'match-card--live-anim': index < 3 }"
@click="goMatch(match.id)"
>
<div class="match-info">
@@ -215,17 +216,17 @@ function formatKickoff(startTime: string) {
opacity: 0;
}
.hz-path-main {
.match-card--live-anim .hz-path-main {
animation: hz-strike-main 2.6s ease-in-out infinite;
}
.hz-path-sub {
.match-card--live-anim .hz-path-sub {
stroke-width: 1.6;
animation: hz-strike-sub 2.6s ease-in-out infinite;
animation-delay: 0.12s;
}
.hz-beam {
.match-card--live-anim .hz-beam {
position: absolute;
left: 0;
right: 0;
@@ -247,12 +248,20 @@ function formatKickoff(startTime: string) {
animation: hz-beam-flash 2.6s ease-in-out infinite;
}
.hz-beam {
display: none;
}
.vs-img {
position: relative;
z-index: 0;
width: 48px;
height: auto;
object-fit: contain;
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
}
.match-card--live-anim .vs-img {
animation: vs-glow 2.4s ease-in-out infinite;
}

View File

@@ -2,23 +2,36 @@
import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { defaultPhoneIsoForLocale, getPhoneDialFromIso } from '@thebet365/shared';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
import RobotVerify from '../components/RobotVerify.vue';
import loginBg from '../assets/images/h5bg.png';
const { t } = useI18n();
type LoginMode = 'account' | 'phone';
const { t, locale } = useI18n();
const { syncLocaleToBackend } = useAppLocale();
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
const username = ref('');
const loginMode = ref<LoginMode>('account');
const account = ref('');
const phone = ref('');
const countryIso = ref(defaultPhoneIsoForLocale(locale.value));
const password = ref('');
const error = ref('');
const loading = ref(false);
function switchLoginMode(mode: LoginMode) {
if (loginMode.value === mode) return;
loginMode.value = mode;
error.value = '';
}
async function submit() {
if (!captchaRef.value?.validate()) {
error.value = t('auth.captcha_wrong');
@@ -28,7 +41,10 @@ async function submit() {
loading.value = true;
error.value = '';
try {
await auth.login(username.value, password.value);
const isPhone = loginMode.value === 'phone';
const loginId = isPhone ? phone.value : account.value;
const countryCode = isPhone ? getPhoneDialFromIso(countryIso.value) : undefined;
await auth.login(loginId, password.value, countryCode);
await syncLocaleToBackend();
const redirectTo = (route.query.redirect as string) || '/';
router.push(redirectTo);
@@ -47,7 +63,6 @@ function isGuestBrowsablePath(path: string): boolean {
function continueBrowsing() {
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '';
// redirect 是登录成功后的目标;跳过登录时只能去公开页,避免跳回需登录页形成死循环
const target = isGuestBrowsablePath(redirect) ? redirect || '/' : '/';
router.replace(target);
}
@@ -66,20 +81,42 @@ function goRegister() {
<LocaleSwitcher compact />
</div>
<form @submit.prevent="submit" class="login-form ps-gold-frame">
<div class="field">
<div v-if="loginMode === 'account'" class="field">
<label>{{ t('auth.username') }}</label>
<input
v-model="username"
v-model="account"
class="field-input"
required
autocomplete="username"
:placeholder="t('auth.login_username_placeholder')"
maxlength="32"
:placeholder="t('auth.username_placeholder')"
/>
</div>
<div v-else class="field">
<label>{{ t('auth.phone') }}</label>
<div class="inline-row">
<PhoneCountrySelect v-model="countryIso" />
<input
v-model="phone"
class="field-input"
type="tel"
required
autocomplete="tel-national"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<input v-model="password" class="field-input" type="password" required />
<input v-model="password" class="field-input" type="password" required autocomplete="current-password" />
</div>
<button type="button" class="btn-mode-switch" @click="switchLoginMode(loginMode === 'account' ? 'phone' : 'account')">
{{ loginMode === 'account' ? t('auth.login_by_phone') : t('auth.login_by_account') }}
</button>
<RobotVerify ref="captchaRef" />
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
@@ -169,6 +206,22 @@ label {
letter-spacing: 0.04em;
}
.btn-mode-switch {
align-self: flex-start;
margin: -2px 0 0;
padding: 0;
border: none;
background: transparent;
color: rgba(240, 216, 117, 0.75);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.btn-mode-switch:active {
color: rgba(240, 216, 117, 0.95);
}
.btn-login {
margin-top: 4px;
padding: 10px 14px;

View File

@@ -105,12 +105,10 @@ async function loadMyBets() {
if (!match.value || !auth.token) return;
loadingMyBets.value = true;
try {
const { data } = await api.get('/player/bets?page=1');
const items = (data.data?.items ?? data.data ?? []) as MyBet[];
const matchTitle = `${match.value.homeTeamName} vs ${match.value.awayTeamName}`;
myBets.value = items.filter(
(b) => b.matchTitle === matchTitle || b.matchTitle === `${match.value!.awayTeamName} vs ${match.value!.homeTeamName}`,
);
const { data } = await api.get('/player/bets', {
params: { page: 1, matchId: match.value.id },
});
myBets.value = (data.data?.items ?? data.data ?? []) as MyBet[];
} catch {
myBets.value = [];
} finally {

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { defaultPhoneIsoForLocale, getPhoneDialFromIso } from '@thebet365/shared';
import { defaultPhoneIsoForLocale, getPhoneDialFromIso, isValidPlayerUsername } from '@thebet365/shared';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import { useSmsCode } from '../composables/useSmsCode';
@@ -16,6 +16,7 @@ const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const account = ref('');
const phone = ref('');
const countryIso = ref(defaultPhoneIsoForLocale(locale.value));
const password = ref('');
@@ -32,6 +33,14 @@ async function sendCode() {
}
async function submit() {
if (!account.value.trim()) {
error.value = t('auth.username_required');
return;
}
if (!isValidPlayerUsername(account.value)) {
error.value = t('auth.username_format_invalid');
return;
}
if (!sessionId.value) {
error.value = t('auth.sms_required');
return;
@@ -48,6 +57,7 @@ async function submit() {
error.value = '';
try {
await auth.register(
account.value,
phone.value,
getPhoneDialFromIso(countryIso.value),
password.value,
@@ -97,6 +107,19 @@ const fieldError = () => {
<form @submit.prevent="submit" class="login-form ps-gold-frame">
<h2 class="form-title">{{ t('auth.register') }}</h2>
<div class="field">
<label>{{ t('auth.username') }}</label>
<input
v-model="account"
class="field-input"
required
autocomplete="username"
maxlength="32"
minlength="7"
:placeholder="t('auth.username_register_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('auth.phone') }}</label>
<div class="inline-row">