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

@@ -1,7 +1,8 @@
import { useI18n } from 'vue-i18n';
import { SUPPORTED_LOCALES, LOCALE_UI_LABELS } from '@thebet365/shared';
import { SUPPORTED_LOCALES, LOCALE_UI_LABELS, type Locale } from '@thebet365/shared';
import api from '../api';
import { useAuthStore } from '../stores/auth';
import { ensurePlayerLocale } from '../i18n';
const STORAGE_KEY = 'locale';
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
@@ -17,7 +18,8 @@ function persistLocale(code: string) {
}
export function useAppLocale() {
const { locale } = useI18n();
const i18n = useI18n({ useScope: 'global' });
const { locale } = i18n;
const auth = useAuthStore();
function applyLocale(code: string) {
@@ -41,6 +43,7 @@ export function useAppLocale() {
/* 离线或 token 过期时仍保留本地语言 */
}
}
await ensurePlayerLocale(i18n, code as Locale);
applyLocale(code);
}
@@ -58,8 +61,9 @@ export function useAppLocale() {
}
}
function initFromUser(userLocale?: string | null) {
async function initFromUser(userLocale?: string | null) {
if (userLocale && (SUPPORTED_LOCALES as readonly string[]).includes(userLocale)) {
await ensurePlayerLocale(i18n, userLocale as Locale);
applyLocale(userLocale);
}
}

View File

@@ -0,0 +1,149 @@
import { ref, shallowRef } from 'vue';
import api from '../api';
import type { MatchPhase } from '../utils/matchPhase';
export interface PlayerMatchSummary {
id: string;
leagueId?: string;
homeTeamName: string;
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamLogoUrl?: string | null;
awayTeamLogoUrl?: string | null;
startTime: string;
leagueName: string;
leagueLogoUrl?: string | null;
displayOrder?: number;
isHot?: boolean;
status?: string;
bettingOpen?: boolean;
matchPhase?: MatchPhase;
score?: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
} | null;
}
export interface ParlayMarketSelection {
id: string;
selectionCode: string;
selectionName: string;
odds: string;
oddsVersion: string;
}
export interface ParlayMarket {
id: string;
marketType: string;
lineValue?: string | number | null;
allowParlay?: boolean;
selections: ParlayMarketSelection[];
}
export interface ParlayMatch extends PlayerMatchSummary {
markets: ParlayMarket[];
}
const summaryMatches = shallowRef<PlayerMatchSummary[]>([]);
const parlayMatches = shallowRef<ParlayMatch[]>([]);
const summaryLoading = ref(false);
const parlayLoading = ref(false);
let summaryInflight: Promise<void> | null = null;
let parlayInflight: Promise<void> | null = null;
async function loadSummary(force = false): Promise<void> {
if (force) summaryMatches.value = [];
if (!force && summaryMatches.value.length > 0) return;
if (summaryInflight) return summaryInflight;
summaryLoading.value = true;
summaryInflight = (async () => {
try {
const { data } = await api.get('/player/matches');
summaryMatches.value = (data.data ?? []) as PlayerMatchSummary[];
} catch {
if (!summaryMatches.value.length) summaryMatches.value = [];
} finally {
summaryLoading.value = false;
summaryInflight = null;
}
})();
return summaryInflight;
}
async function loadParlay(force = false): Promise<void> {
if (force) parlayMatches.value = [];
const hadData = parlayMatches.value.length > 0;
if (!force && hadData) {
if (parlayInflight) return parlayInflight;
parlayInflight = (async () => {
try {
const { data } = await api.get('/player/matches', { params: { scope: 'parlay' } });
mergeParlayOdds((data.data ?? []) as ParlayMatch[]);
} finally {
parlayInflight = null;
}
})();
return parlayInflight;
}
if (parlayInflight) return parlayInflight;
parlayLoading.value = true;
parlayInflight = (async () => {
try {
const { data } = await api.get('/player/matches', { params: { scope: 'parlay' } });
parlayMatches.value = (data.data ?? []) as ParlayMatch[];
} catch {
parlayMatches.value = [];
} finally {
parlayLoading.value = false;
parlayInflight = null;
}
})();
return parlayInflight;
}
function mergeParlayOdds(fresh: ParlayMatch[]) {
const matchMap = new Map<string, ParlayMatch>();
for (const m of fresh) matchMap.set(m.id, m);
for (const match of parlayMatches.value) {
const freshMatch = matchMap.get(match.id);
if (!freshMatch) continue;
const marketMap = new Map<string, ParlayMarket>();
for (const mk of freshMatch.markets) marketMap.set(mk.id, mk);
for (const market of match.markets) {
const freshMarket = marketMap.get(market.id);
if (!freshMarket) continue;
const selMap = new Map<string, ParlayMarketSelection>();
for (const s of freshMarket.selections) selMap.set(s.id, s);
for (const sel of market.selections) {
const fs = selMap.get(sel.id);
if (fs) {
sel.odds = fs.odds;
sel.oddsVersion = fs.oddsVersion;
}
}
}
}
}
/** 赛事列表与串关共享缓存,避免重复拉取大 JSON */
export function usePlayerMatches() {
return {
summaryMatches,
parlayMatches,
summaryLoading,
parlayLoading,
loadSummary,
loadParlay,
};
}