feat(player): 接入创蓝短信手机注册与登录页优化
新增 SMS 验证码注册、8 国手机号选择与 Redis 频控;优化登录/注册 UI 及图形验证码样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
37
packages/shared/scripts/generate-phone-countries.mjs
Normal file
37
packages/shared/scripts/generate-phone-countries.mjs
Normal 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}`);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
104
packages/shared/src/phone-countries.ts
Normal file
104
packages/shared/src/phone-countries.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
}
|
||||
66
packages/shared/src/phone-dial-codes.json
Normal file
66
packages/shared/src/phone-dial-codes.json
Normal 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"
|
||||
}
|
||||
]
|
||||
@@ -7,6 +7,7 @@
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
|
||||
Reference in New Issue
Block a user