feat(player): 接入创蓝短信手机注册与登录页优化

新增 SMS 验证码注册、8 国手机号选择与 Redis 频控;优化登录/注册 UI 及图形验证码样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 10:25:59 +08:00
parent 168aecfd5c
commit db28390be9
39 changed files with 1521 additions and 107 deletions

View File

@@ -819,6 +819,51 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Payment method is required',
'ms-MY': 'Kaedah pembayaran diperlukan',
},
PHONE_REQUIRED: {
'zh-CN': '请填写手机号',
'en-US': 'Phone number is required',
'ms-MY': 'Nombor telefon diperlukan',
},
PHONE_INVALID: {
'zh-CN': '手机号格式无效',
'en-US': 'Invalid phone number',
'ms-MY': 'Nombor telefon tidak sah',
},
PHONE_TAKEN: {
'zh-CN': '该手机号已注册',
'en-US': 'This phone number is already registered',
'ms-MY': 'Nombor telefon ini sudah didaftarkan',
},
SMS_CODE_REQUIRED: {
'zh-CN': '请填写短信验证码',
'en-US': 'SMS verification code is required',
'ms-MY': 'Kod pengesahan SMS diperlukan',
},
SMS_CODE_INVALID: {
'zh-CN': '验证码错误',
'en-US': 'Incorrect verification code',
'ms-MY': 'Kod pengesahan salah',
},
SMS_CODE_EXPIRED: {
'zh-CN': '验证码已过期,请重新获取',
'en-US': 'Verification code expired, please request a new one',
'ms-MY': 'Kod pengesahan tamat tempoh, sila minta yang baharu',
},
SMS_RATE_LIMIT: {
'zh-CN': '发送太频繁请60秒后再试',
'en-US': 'Too many requests, please try again in 60 seconds',
'ms-MY': 'Terlalu kerap, sila cuba lagi dalam 60 saat',
},
SMS_SEND_FAILED: {
'zh-CN': '短信发送失败,请稍后重试',
'en-US': 'Failed to send SMS, please try again later',
'ms-MY': 'Gagal menghantar SMS, sila cuba lagi',
},
PHONE_COUNTRY_UNSUPPORTED: {
'zh-CN': '暂不支持该国家/地区',
'en-US': 'This country or region is not supported',
'ms-MY': 'Negara atau wilayah ini tidak disokong',
},
} as const satisfies Record<string, Record<Locale, string>>;
export type ApiErrorCode = keyof typeof API_ERROR_MESSAGES;

View File

@@ -127,6 +127,7 @@ export * from './builtinPlayers';
export * from './playerLocale';
export * from './playerUsername';
export * from './initial-depositRemark';
export * from './phone-countries';
export interface ApiResponse<T = unknown> {
success: boolean;

View File

@@ -0,0 +1,104 @@
import { normalizeLocale } from './api-errors';
import dialCodesJson from './phone-dial-codes.json';
type PhoneLocale = 'zh-CN' | 'ms-MY' | 'en-US';
export interface PhoneCountry {
iso: string;
dial: string;
nameEn: string;
nameLocal: string;
flag: string;
region: string;
}
/** 平台开放注册/短信的国家ISO 3166-1 alpha-2顺序即下拉展示顺序 */
export const ALLOWED_PHONE_ISO = ['MY', 'SG', 'IN', 'AU', 'TH', 'VN', 'BD', 'TW'] as const;
export type AllowedPhoneIso = (typeof ALLOWED_PHONE_ISO)[number];
const ALLOWED_SET = new Set<string>(ALLOWED_PHONE_ISO);
const ZH_LABELS: Record<AllowedPhoneIso, string> = {
MY: '马来西亚',
SG: '新加坡',
IN: '印度',
AU: '澳洲',
TH: '泰国',
VN: '越南',
BD: '孟加拉国',
TW: '台湾',
};
const allCountries = dialCodesJson as PhoneCountry[];
/** 开放国家列表(从 ITU 全量数据中筛选) */
export const PHONE_COUNTRIES: PhoneCountry[] = ALLOWED_PHONE_ISO.map((iso) => {
const found = allCountries.find((c) => c.iso === iso);
if (!found) {
throw new Error(`Missing phone country data for ISO: ${iso}`);
}
return found;
});
const DIAL_SET = new Set(PHONE_COUNTRIES.map((c) => c.dial));
export function isSupportedPhoneDial(dial: string): boolean {
return DIAL_SET.has(dial.replace(/\D/g, ''));
}
export function isAllowedPhoneIso(iso: string): boolean {
return ALLOWED_SET.has(iso.toUpperCase());
}
export function defaultPhoneDialForLocale(localeInput?: string | null): string {
return findPhoneCountryByIso(defaultPhoneIsoForLocale(localeInput))?.dial ?? '60';
}
export function defaultPhoneIsoForLocale(localeInput?: string | null): string {
const locale = normalizeLocale(localeInput);
if (locale === 'zh-CN') return 'TW';
if (locale === 'ms-MY') return 'MY';
return 'SG';
}
export function getPhoneDialFromIso(iso: string): string {
return findPhoneCountryByIso(iso)?.dial ?? '';
}
export function getPhoneCountryLabel(country: PhoneCountry, localeInput?: string | null): string {
const locale = normalizeLocale(localeInput);
if (locale === 'zh-CN' && isAllowedPhoneIso(country.iso)) {
return ZH_LABELS[country.iso as AllowedPhoneIso];
}
if (locale === 'zh-CN') {
return country.nameLocal || country.nameEn;
}
return country.nameEn;
}
export function findPhoneCountryByDial(dial: string): PhoneCountry | undefined {
const normalized = dial.replace(/\D/g, '');
return PHONE_COUNTRIES.find((c) => c.dial === normalized);
}
export function findPhoneCountryByIso(iso: string): PhoneCountry | undefined {
const upper = iso.toUpperCase();
if (!isAllowedPhoneIso(upper)) return undefined;
return PHONE_COUNTRIES.find((c) => c.iso === upper);
}
export function searchPhoneCountries(query: string, localeInput?: string | null): PhoneCountry[] {
const q = query.trim().toLowerCase();
if (!q) return PHONE_COUNTRIES;
return PHONE_COUNTRIES.filter((country) => {
const label = getPhoneCountryLabel(country, localeInput).toLowerCase();
return (
country.iso.toLowerCase().includes(q)
|| country.dial.includes(q.replace(/^\+/, ''))
|| country.nameEn.toLowerCase().includes(q)
|| label.includes(q)
|| `+${country.dial}`.includes(q)
);
});
}

View File

@@ -0,0 +1,66 @@
[
{
"iso": "MY",
"dial": "60",
"nameEn": "Malaysia",
"nameLocal": "Malaysia",
"flag": "🇲🇾",
"region": "Asia & Pacific"
},
{
"iso": "SG",
"dial": "65",
"nameEn": "Singapore",
"nameLocal": "Singapore",
"flag": "🇸🇬",
"region": "Asia & Pacific"
},
{
"iso": "IN",
"dial": "91",
"nameEn": "India",
"nameLocal": "भारत, India",
"flag": "🇮🇳",
"region": "Asia & Pacific"
},
{
"iso": "AU",
"dial": "61",
"nameEn": "Australia",
"nameLocal": "Australia",
"flag": "🇦🇺",
"region": "Asia & Pacific"
},
{
"iso": "TH",
"dial": "66",
"nameEn": "Thailand",
"nameLocal": "ประเทศไทย",
"flag": "🇹🇭",
"region": "Asia & Pacific"
},
{
"iso": "VN",
"dial": "84",
"nameEn": "Vietnam",
"nameLocal": "Việt Nam",
"flag": "🇻🇳",
"region": "Asia & Pacific"
},
{
"iso": "BD",
"dial": "880",
"nameEn": "Bangladesh",
"nameLocal": "গণপ্রজাতন্ত্রী বাংলাদেশ",
"flag": "🇧🇩",
"region": "Asia & Pacific"
},
{
"iso": "TW",
"dial": "886",
"nameEn": "Taiwan, Province of China",
"nameLocal": "Taiwan",
"flag": "🇹🇼",
"region": "Asia & Pacific"
}
]