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

@@ -5,10 +5,12 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"generate:phone-countries": "node scripts/generate-phone-countries.mjs",
"build": "pnpm run generate:phone-countries && tsc",
"dev": "tsc --watch"
},
"devDependencies": {
"country-codes-list": "^2.0.0",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,37 @@
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { all } from 'country-codes-list';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '../src/phone-dial-codes.json');
/** 平台开放注册/短信的国家ISO 3166-1 alpha-2 */
const ALLOWED_PHONE_ISO = ['MY', 'SG', 'IN', 'AU', 'TH', 'VN', 'BD', 'TW'];
/** 平台常用市场置顶(须为 ALLOWED_PHONE_ISO 子集) */
const PINNED_ISO = ['MY', 'SG', 'IN', 'AU', 'TH', 'VN', 'BD', 'TW'];
const entries = all()
.filter((c) => c.countryCallingCode && c.countryCode && ALLOWED_PHONE_ISO.includes(c.countryCode))
.map((c) => ({
iso: c.countryCode,
dial: c.countryCallingCode,
nameEn: c.countryNameEn,
nameLocal: c.countryNameLocal || c.countryNameEn,
flag: c.flag || '',
region: c.region || '',
}))
.sort((a, b) => {
const pa = PINNED_ISO.indexOf(a.iso);
const pb = PINNED_ISO.indexOf(b.iso);
if (pa !== -1 || pb !== -1) {
if (pa === -1) return 1;
if (pb === -1) return -1;
return pa - pb;
}
return a.nameEn.localeCompare(b.nameEn, 'en');
});
writeFileSync(outPath, `${JSON.stringify(entries, null, 2)}\n`, 'utf8');
console.log(`Wrote ${entries.length} countries to ${outPath}`);

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"
}
]

View File

@@ -7,6 +7,7 @@
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src/**/*"]