feat(player): 注册账号、登录双模式与移动端性能优化
注册必填 7-32 位账号,手机号区号/本地号分存;登录默认账号模式并支持切换手机号登录;Player i18n 拆包与赛事接口优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user