feat(player): 接入创蓝短信手机注册与登录页优化
新增 SMS 验证码注册、8 国手机号选择与 Redis 频控;优化登录/注册 UI 及图形验证码样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,3 +17,12 @@ SEED_DATABASE=true
|
|||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
PLAYER_PORT=8082
|
PLAYER_PORT=8082
|
||||||
ADMIN_PORT=8081
|
ADMIN_PORT=8081
|
||||||
|
|
||||||
|
# 创蓝短信(Chuanglan,仅 API 使用)
|
||||||
|
CHUANGLAN_ACCOUNT=your_account
|
||||||
|
CHUANGLAN_PASSWORD=your_password
|
||||||
|
CHUANGLAN_ENDPOINT=https://sgap.253.com/send/sms
|
||||||
|
CHUANGLAN_CONNECT_TIMEOUT_MS=10000
|
||||||
|
CHUANGLAN_READ_TIMEOUT_MS=10000
|
||||||
|
SMS_CODE_TTL_SECONDS=300
|
||||||
|
SMS_RATE_LIMIT_SECONDS=60
|
||||||
|
|||||||
@@ -7,3 +7,12 @@ JWT_AGENT_EXPIRES=8h
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
UPLOAD_DIR=
|
UPLOAD_DIR=
|
||||||
|
|
||||||
|
# 创蓝短信(Chuanglan)
|
||||||
|
CHUANGLAN_ACCOUNT=your_account
|
||||||
|
CHUANGLAN_PASSWORD=your_password
|
||||||
|
CHUANGLAN_ENDPOINT=https://sgap.253.com/send/sms
|
||||||
|
CHUANGLAN_CONNECT_TIMEOUT_MS=10000
|
||||||
|
CHUANGLAN_READ_TIMEOUT_MS=10000
|
||||||
|
SMS_CODE_TTL_SECONDS=300
|
||||||
|
SMS_RATE_LIMIT_SECONDS=60
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { JwtAuthGuard } from './domains/identity/guards';
|
import { JwtAuthGuard } from './domains/identity/guards';
|
||||||
import { PrismaModule } from './shared/prisma/prisma.module';
|
import { PrismaModule } from './shared/prisma/prisma.module';
|
||||||
|
import { RedisModule } from './shared/redis/redis.module';
|
||||||
import { SystemConfigModule } from './shared/config/system-config.module';
|
import { SystemConfigModule } from './shared/config/system-config.module';
|
||||||
import { IdentityModule } from './domains/identity/identity.module';
|
import { IdentityModule } from './domains/identity/identity.module';
|
||||||
import { AgentsModule } from './domains/agent/agents.module';
|
import { AgentsModule } from './domains/agent/agents.module';
|
||||||
@@ -22,6 +23,7 @@ import { AgentPortalModule } from './applications/agent/agent-portal.module';
|
|||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
RedisModule,
|
||||||
SystemConfigModule,
|
SystemConfigModule,
|
||||||
IdentityModule,
|
IdentityModule,
|
||||||
AgentsModule,
|
AgentsModule,
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('player/auth/login')
|
@Post('player/auth/login')
|
||||||
async playerLogin(@Body() dto: LoginDto) {
|
async playerLogin(@Body() dto: LoginDto) {
|
||||||
const result = await this.auth.login(dto.username, dto.password, 'player');
|
const result = await this.auth.login(
|
||||||
|
dto.username,
|
||||||
|
dto.password,
|
||||||
|
'player',
|
||||||
|
dto.countryCode,
|
||||||
|
);
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +33,11 @@ export class AuthController {
|
|||||||
@Post('player/auth/register')
|
@Post('player/auth/register')
|
||||||
async playerRegister(@Body() dto: RegisterDto) {
|
async playerRegister(@Body() dto: RegisterDto) {
|
||||||
const result = await this.auth.registerPlayer({
|
const result = await this.auth.registerPlayer({
|
||||||
username: dto.username,
|
phone: dto.phone,
|
||||||
|
countryCode: dto.countryCode,
|
||||||
password: dto.password,
|
password: dto.password,
|
||||||
|
smsCode: dto.smsCode,
|
||||||
|
sessionId: dto.sessionId,
|
||||||
inviteCode: dto.inviteCode,
|
inviteCode: dto.inviteCode,
|
||||||
locale: dto.locale,
|
locale: dto.locale,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,18 +10,35 @@ export class LoginDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '国家区号,不含 +,如 86' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
countryCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RegisterDto {
|
export class RegisterDto {
|
||||||
@ApiProperty()
|
@ApiProperty({ description: '本地手机号,不含国家区号' })
|
||||||
@IsString()
|
@IsString()
|
||||||
username!: string;
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '国家区号,不含 +,如 86' })
|
||||||
|
@IsString()
|
||||||
|
countryCode!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '短信验证码' })
|
||||||
|
@IsString()
|
||||||
|
smsCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '发送验证码返回的 sessionId' })
|
||||||
|
@IsString()
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { InvitesService } from './invites.service';
|
|||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { SystemConfigModule } from '../../shared/config/system-config.module';
|
import { SystemConfigModule } from '../../shared/config/system-config.module';
|
||||||
|
import { SmsModule } from './sms/sms.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
SmsModule,
|
||||||
SystemConfigModule,
|
SystemConfigModule,
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { assertPlayerUsername } from '@thebet365/shared';
|
|||||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
import { InvitesService } from './invites.service';
|
import { InvitesService } from './invites.service';
|
||||||
|
import { SmsService } from './sms/sms.service';
|
||||||
|
import { normalizePhone, resolvePlayerLoginUsername } from './sms/phone.util';
|
||||||
|
|
||||||
const MAX_LOGIN_FAILS = 5;
|
const MAX_LOGIN_FAILS = 5;
|
||||||
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
||||||
@@ -27,6 +29,7 @@ export class AuthService {
|
|||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private systemConfig: SystemConfigService,
|
private systemConfig: SystemConfigService,
|
||||||
private invites: InvitesService,
|
private invites: InvitesService,
|
||||||
|
private sms: SmsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */
|
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */
|
||||||
@@ -42,9 +45,19 @@ export class AuthService {
|
|||||||
return this.login(username, password, portal);
|
return this.login(username, password, portal);
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(username: string, password: string, portal: 'player' | 'admin' | 'agent') {
|
async login(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
portal: 'player' | 'admin' | 'agent',
|
||||||
|
countryCode?: string,
|
||||||
|
) {
|
||||||
|
const lookupUsername =
|
||||||
|
portal === 'player'
|
||||||
|
? resolvePlayerLoginUsername(username, countryCode)
|
||||||
|
: username.trim();
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { username },
|
where: { username: lookupUsername },
|
||||||
include: { auth: true, adminRole: { include: { role: true } } },
|
include: { auth: true, adminRole: { include: { role: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,23 +156,35 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async registerPlayer(data: {
|
async registerPlayer(data: {
|
||||||
username: string;
|
phone: string;
|
||||||
|
countryCode: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
smsCode: string;
|
||||||
|
sessionId: string;
|
||||||
inviteCode?: string;
|
inviteCode?: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
}) {
|
}) {
|
||||||
const username = data.username.trim();
|
const phone = normalizePhone(data.countryCode, data.phone);
|
||||||
if (!username) {
|
const username = phone;
|
||||||
throw appBadRequest('USERNAME_REQUIRED');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
assertPlayerUsername(username);
|
assertPlayerUsername(username);
|
||||||
} catch {
|
} catch {
|
||||||
throw appBadRequest('USERNAME_FORMAT_INVALID');
|
throw appBadRequest('PHONE_INVALID');
|
||||||
}
|
}
|
||||||
if (!data.password || data.password.length < 8) {
|
if (!data.password || data.password.length < 8) {
|
||||||
throw appBadRequest('PASSWORD_MIN_LENGTH');
|
throw appBadRequest('PASSWORD_MIN_LENGTH');
|
||||||
}
|
}
|
||||||
|
if (!data.smsCode?.trim() || !data.sessionId?.trim()) {
|
||||||
|
throw appBadRequest('SMS_CODE_REQUIRED');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sms.verifyCode({
|
||||||
|
phone: data.phone,
|
||||||
|
countryCode: data.countryCode,
|
||||||
|
code: data.smsCode.trim(),
|
||||||
|
sessionId: data.sessionId.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode);
|
const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode);
|
||||||
const inviteSponsorId = parentId == null && sponsorId != null ? sponsorId : null;
|
const inviteSponsorId = parentId == null && sponsorId != null ? sponsorId : null;
|
||||||
@@ -169,7 +194,7 @@ export class AuthService {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw appBadRequest('USERNAME_TAKEN');
|
throw appBadRequest('PHONE_TAKEN');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = await this.hashPassword(data.password);
|
const hash = await this.hashPassword(data.password);
|
||||||
@@ -195,7 +220,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await tx.userPreference.create({
|
await tx.userPreference.create({
|
||||||
data: { userId: created.id, locale },
|
data: { userId: created.id, locale, phone },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (inviteId) {
|
if (inviteId) {
|
||||||
|
|||||||
61
apps/api/src/domains/identity/sms/chuanglan/client.ts
Normal file
61
apps/api/src/domains/identity/sms/chuanglan/client.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { generateChuanglanSign } from './sign';
|
||||||
|
import { loadChuanglanConfig } from './config';
|
||||||
|
import type { ChuanglanSendResponse, SmsSendResult } from '../sms.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChuanglanClient {
|
||||||
|
private readonly cfg;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
this.cfg = loadChuanglanConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSms(mobile: string, msg: string, uid?: string): Promise<SmsSendResult> {
|
||||||
|
const nonce = String(Date.now());
|
||||||
|
|
||||||
|
const body: Record<string, string> = {
|
||||||
|
account: this.cfg.account,
|
||||||
|
mobile,
|
||||||
|
msg,
|
||||||
|
};
|
||||||
|
if (uid) body.uid = uid;
|
||||||
|
|
||||||
|
const sign = generateChuanglanSign(this.cfg.password, { ...body, nonce });
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), this.cfg.readTimeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.cfg.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
nonce,
|
||||||
|
sign,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await res.json()) as ChuanglanSendResponse;
|
||||||
|
|
||||||
|
if (data.code === '0') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
code: data.code,
|
||||||
|
message: 'OK',
|
||||||
|
messageId: data.data?.messageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, code: data.code, message: data.message };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
return { success: false, code: 'HTTP_ERROR', message };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/api/src/domains/identity/sms/chuanglan/config.ts
Normal file
36
apps/api/src/domains/identity/sms/chuanglan/config.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
export interface ChuanglanConfig {
|
||||||
|
account: string;
|
||||||
|
password: string;
|
||||||
|
endpoint: string;
|
||||||
|
connectTimeoutMs: number;
|
||||||
|
readTimeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsBusinessConfig {
|
||||||
|
codeTtlSeconds: number;
|
||||||
|
rateLimitSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadChuanglanConfig(config: ConfigService): ChuanglanConfig {
|
||||||
|
const account = config.get<string>('CHUANGLAN_ACCOUNT');
|
||||||
|
const password = config.get<string>('CHUANGLAN_PASSWORD');
|
||||||
|
if (!account || !password) {
|
||||||
|
throw new Error('Missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
password,
|
||||||
|
endpoint: config.get('CHUANGLAN_ENDPOINT', 'https://sgap.253.com/send/sms'),
|
||||||
|
connectTimeoutMs: Number(config.get('CHUANGLAN_CONNECT_TIMEOUT_MS', 10_000)),
|
||||||
|
readTimeoutMs: Number(config.get('CHUANGLAN_READ_TIMEOUT_MS', 10_000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSmsBusinessConfig(config: ConfigService): SmsBusinessConfig {
|
||||||
|
return {
|
||||||
|
codeTtlSeconds: Number(config.get('SMS_CODE_TTL_SECONDS', 300)),
|
||||||
|
rateLimitSeconds: Number(config.get('SMS_RATE_LIMIT_SECONDS', 60)),
|
||||||
|
};
|
||||||
|
}
|
||||||
18
apps/api/src/domains/identity/sms/chuanglan/sign.ts
Normal file
18
apps/api/src/domains/identity/sms/chuanglan/sign.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
export function generateChuanglanSign(
|
||||||
|
password: string,
|
||||||
|
params: Record<string, string | undefined>,
|
||||||
|
): string {
|
||||||
|
const raw = Object.keys(params)
|
||||||
|
.sort()
|
||||||
|
.reduce((acc, key) => {
|
||||||
|
const value = params[key];
|
||||||
|
if (value != null && value.trim() !== '') {
|
||||||
|
return acc + key + value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, '');
|
||||||
|
|
||||||
|
return createHash('md5').update(raw + password, 'utf8').digest('hex').toLowerCase();
|
||||||
|
}
|
||||||
3
apps/api/src/domains/identity/sms/code.ts
Normal file
3
apps/api/src/domains/identity/sms/code.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function generateSixDigitCode(): string {
|
||||||
|
return String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0');
|
||||||
|
}
|
||||||
52
apps/api/src/domains/identity/sms/phone.util.ts
Normal file
52
apps/api/src/domains/identity/sms/phone.util.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { appBadRequest } from '../../../shared/common/app-error';
|
||||||
|
import { isSupportedPhoneDial } from '@thebet365/shared';
|
||||||
|
|
||||||
|
/** 归一化为创蓝格式:国家区号 + 本地号码(纯数字,无 +) */
|
||||||
|
export function normalizePhone(countryDial: string, localInput: string): string {
|
||||||
|
const dial = countryDial.replace(/\D/g, '');
|
||||||
|
let local = localInput.replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (!dial || !local) {
|
||||||
|
throw appBadRequest('PHONE_REQUIRED');
|
||||||
|
}
|
||||||
|
if (!isSupportedPhoneDial(dial)) {
|
||||||
|
throw appBadRequest('PHONE_COUNTRY_UNSUPPORTED');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 马来西亚等本地号常以 0 开头
|
||||||
|
if (local.startsWith('0') && local.length > 1) {
|
||||||
|
local = local.replace(/^0+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = `${dial}${local}`;
|
||||||
|
if (combined.length < 10 || combined.length > 15) {
|
||||||
|
throw appBadRequest('PHONE_INVALID');
|
||||||
|
}
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 玩家登录:选国家 + 本地号;字母账号(如 player1)原样返回 */
|
||||||
|
export function resolvePlayerLoginUsername(
|
||||||
|
input: string,
|
||||||
|
countryDial?: string,
|
||||||
|
): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
|
||||||
|
if (!/^[\d+\s\-()]+$/.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countryDial?.trim()) {
|
||||||
|
try {
|
||||||
|
return normalizePhone(countryDial, trimmed);
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = trimmed.replace(/\D/g, '');
|
||||||
|
if (digits.length >= 10) return digits;
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
26
apps/api/src/domains/identity/sms/sms.controller.ts
Normal file
26
apps/api/src/domains/identity/sms/sms.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Controller, Post, Body, Req } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { SmsService } from './sms.service';
|
||||||
|
import { SendSmsCodeDto } from './sms.dto';
|
||||||
|
import { Public } from '../../../shared/common/decorators';
|
||||||
|
import { jsonResponse } from '../../../shared/common/filters';
|
||||||
|
import { getClientIp } from '../../../shared/common/client-ip.util';
|
||||||
|
|
||||||
|
@ApiTags('SMS')
|
||||||
|
@Controller()
|
||||||
|
export class SmsController {
|
||||||
|
constructor(private sms: SmsService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('player/sms/send')
|
||||||
|
async send(@Body() dto: SendSmsCodeDto, @Req() req: Request) {
|
||||||
|
const result = await this.sms.sendVerifyCode({
|
||||||
|
phone: dto.phone.trim(),
|
||||||
|
countryCode: dto.countryCode.trim(),
|
||||||
|
locale: dto.locale,
|
||||||
|
clientIp: getClientIp(req),
|
||||||
|
});
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/api/src/domains/identity/sms/sms.dto.ts
Normal file
35
apps/api/src/domains/identity/sms/sms.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { IsString, IsOptional } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class SendSmsCodeDto {
|
||||||
|
@ApiProperty({ example: '13800138000', description: '本地手机号,不含国家区号' })
|
||||||
|
@IsString()
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '86', description: '国家区号,不含 +' })
|
||||||
|
@IsString()
|
||||||
|
countryCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VerifySmsCodeDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
countryCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
sessionId!: string;
|
||||||
|
}
|
||||||
11
apps/api/src/domains/identity/sms/sms.module.ts
Normal file
11
apps/api/src/domains/identity/sms/sms.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SmsService } from './sms.service';
|
||||||
|
import { SmsController } from './sms.controller';
|
||||||
|
import { ChuanglanClient } from './chuanglan/client';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [SmsService, ChuanglanClient],
|
||||||
|
controllers: [SmsController],
|
||||||
|
exports: [SmsService],
|
||||||
|
})
|
||||||
|
export class SmsModule {}
|
||||||
95
apps/api/src/domains/identity/sms/sms.service.ts
Normal file
95
apps/api/src/domains/identity/sms/sms.service.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { normalizeLocale } from '@thebet365/shared';
|
||||||
|
import { RedisService } from '../../../shared/redis/redis.service';
|
||||||
|
import { appBadRequest, appTooManyRequests } from '../../../shared/common/app-error';
|
||||||
|
import { ChuanglanClient } from './chuanglan/client';
|
||||||
|
import { loadSmsBusinessConfig } from './chuanglan/config';
|
||||||
|
import { generateSixDigitCode } from './code';
|
||||||
|
import { normalizePhone } from './phone.util';
|
||||||
|
import { renderVerifySms } from './templates';
|
||||||
|
import type { SmsLang } from './sms.types';
|
||||||
|
|
||||||
|
const codeKey = (sessionId: string) => `sms:code:${sessionId}`;
|
||||||
|
const phoneRateKey = (phone: string) => `sms:rate:phone:${phone}`;
|
||||||
|
const ipRateKey = (ip: string) => `sms:rate:ip:${ip}`;
|
||||||
|
|
||||||
|
function mapLocaleToSmsLang(locale?: string): SmsLang {
|
||||||
|
const normalized = normalizeLocale(locale);
|
||||||
|
if (normalized === 'zh-CN') return 'zh';
|
||||||
|
if (normalized === 'ms-MY') return 'ms';
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SmsService {
|
||||||
|
private readonly smsCfg;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private redis: RedisService,
|
||||||
|
private chuanglan: ChuanglanClient,
|
||||||
|
config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.smsCfg = loadSmsBusinessConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendVerifyCode(params: {
|
||||||
|
phone: string;
|
||||||
|
countryCode: string;
|
||||||
|
locale?: string;
|
||||||
|
clientIp: string;
|
||||||
|
}): Promise<{ sessionId: string }> {
|
||||||
|
const phone = normalizePhone(params.countryCode, params.phone);
|
||||||
|
|
||||||
|
const [phoneLimited, ipLimited] = await Promise.all([
|
||||||
|
this.redis.exists(phoneRateKey(phone)),
|
||||||
|
this.redis.exists(ipRateKey(params.clientIp)),
|
||||||
|
]);
|
||||||
|
if (phoneLimited || ipLimited) {
|
||||||
|
throw appTooManyRequests('SMS_RATE_LIMIT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = generateSixDigitCode();
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const lang = mapLocaleToSmsLang(params.locale);
|
||||||
|
const msg = renderVerifySms(lang, code);
|
||||||
|
|
||||||
|
const result = await this.chuanglan.sendSms(phone, msg, sessionId);
|
||||||
|
if (!result.success) {
|
||||||
|
throw appBadRequest('SMS_SEND_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.redis.set(
|
||||||
|
codeKey(sessionId),
|
||||||
|
JSON.stringify({ phone, code }),
|
||||||
|
this.smsCfg.codeTtlSeconds,
|
||||||
|
),
|
||||||
|
this.redis.set(phoneRateKey(phone), '1', this.smsCfg.rateLimitSeconds),
|
||||||
|
this.redis.set(ipRateKey(params.clientIp), '1', this.smsCfg.rateLimitSeconds),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyCode(params: {
|
||||||
|
phone: string;
|
||||||
|
countryCode: string;
|
||||||
|
code: string;
|
||||||
|
sessionId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const phone = normalizePhone(params.countryCode, params.phone);
|
||||||
|
const raw = await this.redis.get(codeKey(params.sessionId));
|
||||||
|
if (!raw) {
|
||||||
|
throw appBadRequest('SMS_CODE_EXPIRED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = JSON.parse(raw) as { phone: string; code: string };
|
||||||
|
if (cached.phone !== phone || cached.code !== params.code.trim()) {
|
||||||
|
throw appBadRequest('SMS_CODE_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.del(codeKey(params.sessionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/domains/identity/sms/sms.types.ts
Normal file
14
apps/api/src/domains/identity/sms/sms.types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type SmsLang = 'zh' | 'en' | 'vi' | 'ms' | 'kh';
|
||||||
|
|
||||||
|
export interface ChuanglanSendResponse {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
data?: { messageId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsSendResult {
|
||||||
|
success: boolean;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
messageId?: string;
|
||||||
|
}
|
||||||
16
apps/api/src/domains/identity/sms/templates.ts
Normal file
16
apps/api/src/domains/identity/sms/templates.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { SmsLang } from './sms.types';
|
||||||
|
|
||||||
|
const TEMPLATES: Record<string, string> = {
|
||||||
|
default: '您的验证码是:{code}。5分钟内有效。',
|
||||||
|
zh: '您的验证码是:{code}。5分钟内有效。',
|
||||||
|
en: 'Your verification code is {code}. Valid for 5 minutes.',
|
||||||
|
vi: 'Mã xác minh của bạn là {code}. Có hiệu lực trong 5 phút.',
|
||||||
|
ms: 'Kod pengesahan anda ialah {code}. Sah selama 5 minit.',
|
||||||
|
kh: 'កូដផ្ទៀងផ្ទាត់របស់អ្នកគឺ {code} ។ មានសុពលភាពរយៈពេល ៥ នាទី។',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderVerifySms(lang: SmsLang | undefined, code: string): string {
|
||||||
|
const key = lang?.trim() || 'zh';
|
||||||
|
const tpl = TEMPLATES[key] ?? TEMPLATES.default ?? TEMPLATES.zh;
|
||||||
|
return tpl.replace('{code}', code);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -33,6 +35,10 @@ export function appUnauthorized(code: ApiErrorCode, params?: ApiErrorParams) {
|
|||||||
return new UnauthorizedException(body(code, params));
|
return new UnauthorizedException(body(code, params));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function appTooManyRequests(code: ApiErrorCode, params?: ApiErrorParams) {
|
||||||
|
return new HttpException(body(code, params), HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
|
||||||
export function isCodedExceptionResponse(
|
export function isCodedExceptionResponse(
|
||||||
res: unknown,
|
res: unknown,
|
||||||
): res is { code: ApiErrorCode; params?: ApiErrorParams } {
|
): res is { code: ApiErrorCode; params?: ApiErrorParams } {
|
||||||
|
|||||||
13
apps/api/src/shared/common/client-ip.util.ts
Normal file
13
apps/api/src/shared/common/client-ip.util.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
export function getClientIp(req: Request): string {
|
||||||
|
const forwarded = req.headers['x-forwarded-for'];
|
||||||
|
if (typeof forwarded === 'string' && forwarded.trim()) {
|
||||||
|
return forwarded.split(',')[0]?.trim() || '0.0.0.0';
|
||||||
|
}
|
||||||
|
const realIp = req.headers['x-real-ip'];
|
||||||
|
if (typeof realIp === 'string' && realIp.trim()) {
|
||||||
|
return realIp.trim();
|
||||||
|
}
|
||||||
|
return req.ip || req.socket?.remoteAddress || '0.0.0.0';
|
||||||
|
}
|
||||||
9
apps/api/src/shared/redis/redis.module.ts
Normal file
9
apps/api/src/shared/redis/redis.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [RedisService],
|
||||||
|
exports: [RedisService],
|
||||||
|
})
|
||||||
|
export class RedisModule {}
|
||||||
37
apps/api/src/shared/redis/redis.service.ts
Normal file
37
apps/api/src/shared/redis/redis.service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisService implements OnModuleDestroy {
|
||||||
|
private readonly client: Redis;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const url = config.get<string>('REDIS_URL', 'redis://127.0.0.1:6379');
|
||||||
|
this.client = new Redis(url, { maxRetriesPerRequest: 3, lazyConnect: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.client.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
get raw(): Redis {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
return (await this.client.exists(key)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
return this.client.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string, ttlSeconds: number): Promise<void> {
|
||||||
|
await this.client.set(key, value, 'EX', ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
await this.client.del(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
251
apps/player/src/components/PhoneCountrySelect.vue
Normal file
251
apps/player/src/components/PhoneCountrySelect.vue
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
PHONE_COUNTRIES,
|
||||||
|
getPhoneCountryLabel,
|
||||||
|
searchPhoneCountries,
|
||||||
|
findPhoneCountryByIso,
|
||||||
|
defaultPhoneIsoForLocale,
|
||||||
|
} from '@thebet365/shared';
|
||||||
|
|
||||||
|
const model = defineModel<string>({ required: true });
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const query = ref('');
|
||||||
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const options = computed(() =>
|
||||||
|
searchPhoneCountries(query.value, locale.value).map((country) => ({
|
||||||
|
iso: country.iso,
|
||||||
|
dial: country.dial,
|
||||||
|
code: `+${country.dial}`,
|
||||||
|
name: getPhoneCountryLabel(country, locale.value),
|
||||||
|
flag: country.flag,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const current = computed(() => {
|
||||||
|
const matched = findPhoneCountryByIso(model.value)
|
||||||
|
?? PHONE_COUNTRIES.find((c) => c.dial === model.value);
|
||||||
|
if (matched) {
|
||||||
|
return {
|
||||||
|
iso: matched.iso,
|
||||||
|
code: `+${matched.dial}`,
|
||||||
|
name: getPhoneCountryLabel(matched, locale.value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { iso: '--', code: '+--', name: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
function pick(iso: string) {
|
||||||
|
model.value = iso;
|
||||||
|
open.value = false;
|
||||||
|
query.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open.value = !open.value;
|
||||||
|
if (open.value) query.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOutsideClick(e: Event) {
|
||||||
|
if (!root.value?.contains(e.target as Node)) {
|
||||||
|
open.value = false;
|
||||||
|
query.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, (isOpen) => {
|
||||||
|
if (!isOpen) query.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!findPhoneCountryByIso(model.value)) {
|
||||||
|
model.value = defaultPhoneIsoForLocale(locale.value);
|
||||||
|
}
|
||||||
|
document.addEventListener('click', onOutsideClick);
|
||||||
|
document.addEventListener('touchend', onOutsideClick);
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', onOutsideClick);
|
||||||
|
document.removeEventListener('touchend', onOutsideClick);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="root" class="country-switch" :class="{ open }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="country-trigger"
|
||||||
|
:aria-expanded="open"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
:title="current.name"
|
||||||
|
@click.stop="toggle"
|
||||||
|
>
|
||||||
|
<span class="country-iso">{{ current.iso }}</span>
|
||||||
|
<span class="country-sep">·</span>
|
||||||
|
<span class="country-dial">{{ current.code }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-show="open" class="country-panel">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
class="country-search"
|
||||||
|
type="search"
|
||||||
|
:placeholder="t('auth.country_search')"
|
||||||
|
autocomplete="off"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<ul class="country-menu" role="listbox">
|
||||||
|
<li
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt.iso"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="model === opt.iso"
|
||||||
|
class="country-option"
|
||||||
|
:class="{ active: model === opt.iso }"
|
||||||
|
@click="pick(opt.iso)"
|
||||||
|
>
|
||||||
|
<span class="country-iso">{{ opt.iso }}</span>
|
||||||
|
<span class="country-dial">{{ opt.code }}</span>
|
||||||
|
<span class="country-name">{{ opt.name }}</span>
|
||||||
|
</li>
|
||||||
|
<li v-if="options.length === 0" class="country-empty">{{ t('auth.country_not_found') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.country-switch {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-trigger:active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-iso {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-dial {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 60;
|
||||||
|
width: min(260px, calc(100vw - 48px));
|
||||||
|
padding: 4px;
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-search {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-search::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-menu {
|
||||||
|
max-height: 180px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-option:hover,
|
||||||
|
.country-option.active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-option .country-iso {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-option .country-dial {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 38px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-option:hover .country-name,
|
||||||
|
.country-option.active .country-name {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-empty {
|
||||||
|
padding: 10px 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,8 @@ const validated = ref(false);
|
|||||||
const errorMsg = ref('');
|
const errorMsg = ref('');
|
||||||
|
|
||||||
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
const CANVAS_W = 96;
|
||||||
|
const CANVAS_H = 36;
|
||||||
|
|
||||||
function generateCode() {
|
function generateCode() {
|
||||||
let result = '';
|
let result = '';
|
||||||
@@ -24,40 +26,39 @@ function generateCode() {
|
|||||||
function drawCaptcha() {
|
function drawCaptcha() {
|
||||||
const canvas = canvasRef.value;
|
const canvas = canvasRef.value;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const w = 108, h = 44;
|
canvas.width = CANVAS_W;
|
||||||
canvas.width = w;
|
canvas.height = CANVAS_H;
|
||||||
canvas.height = h;
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
ctx.fillStyle = '#2a2210';
|
ctx.fillStyle = '#0d0d0d';
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
|
||||||
for (let i = 0; i < 28; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
ctx.fillStyle = `rgba(212, 175, 55, ${0.1 + Math.random() * 0.2})`;
|
ctx.fillStyle = `rgba(255, 255, 255, ${0.03 + Math.random() * 0.06})`;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(Math.random() * w, Math.random() * h, Math.random() * 2.2, 0, Math.PI * 2);
|
ctx.arc(Math.random() * CANVAS_W, Math.random() * CANVAS_H, Math.random() * 1.8, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
ctx.strokeStyle = `rgba(212, 175, 55, ${0.15 + Math.random() * 0.25})`;
|
ctx.strokeStyle = `rgba(255, 255, 255, ${0.06 + Math.random() * 0.08})`;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(Math.random() * w, Math.random() * h);
|
ctx.moveTo(Math.random() * CANVAS_W, Math.random() * CANVAS_H);
|
||||||
ctx.lineTo(Math.random() * w, Math.random() * h);
|
ctx.lineTo(Math.random() * CANVAS_W, Math.random() * CANVAS_H);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
const charWidth = w / (code.value.length + 1);
|
const charWidth = CANVAS_W / (code.value.length + 1);
|
||||||
for (let i = 0; i < code.value.length; i++) {
|
for (let i = 0; i < code.value.length; i++) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
const x = charWidth * (i + 0.5) + (Math.random() - 0.5) * 4;
|
const x = charWidth * (i + 0.5) + (Math.random() - 0.5) * 3;
|
||||||
const y = h / 2 + (Math.random() - 0.5) * 6;
|
const y = CANVAS_H / 2 + (Math.random() - 0.5) * 4;
|
||||||
ctx.translate(x, y);
|
ctx.translate(x, y);
|
||||||
ctx.rotate((Math.random() - 0.5) * 0.4);
|
ctx.rotate((Math.random() - 0.5) * 0.35);
|
||||||
ctx.font = `bold ${18 + Math.random() * 6}px 'Courier New', monospace`;
|
ctx.font = `bold ${15 + Math.random() * 4}px 'Courier New', monospace`;
|
||||||
ctx.fillStyle = `hsl(${40 + Math.random() * 20}, ${80 + Math.random() * 20}%, ${65 + Math.random() * 20}%)`;
|
ctx.fillStyle = `rgba(240, 216, 117, ${0.85 + Math.random() * 0.15})`;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(code.value[i], 0, 0);
|
ctx.fillText(code.value[i], 0, 0);
|
||||||
@@ -94,24 +95,58 @@ defineExpose({ validate, refresh });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="captcha-row">
|
<div class="captcha-wrap">
|
||||||
<input v-model="honeypot" type="text" name="website" tabindex="-1"
|
<div class="captcha-row">
|
||||||
autocomplete="off" class="hp-field" aria-hidden="true" />
|
<input
|
||||||
<input v-model="input" type="text" maxlength="4"
|
v-model="honeypot"
|
||||||
class="captcha-input" :placeholder="t('auth.captcha_placeholder')" autocomplete="off" />
|
type="text"
|
||||||
<canvas ref="canvasRef" class="captcha-canvas"
|
name="website"
|
||||||
:title="t('auth.captcha_refresh')" role="button" tabindex="0"
|
tabindex="-1"
|
||||||
@click="refresh" @keydown.enter="refresh" />
|
autocomplete="off"
|
||||||
|
class="hp-field"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="input"
|
||||||
|
type="text"
|
||||||
|
maxlength="4"
|
||||||
|
class="captcha-input"
|
||||||
|
:placeholder="t('auth.captcha_placeholder')"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
class="captcha-canvas"
|
||||||
|
:width="CANVAS_W"
|
||||||
|
:height="CANVAS_H"
|
||||||
|
:title="t('auth.captcha_refresh')"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="refresh"
|
||||||
|
@keydown.enter="refresh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="errorMsg" class="captcha-error">{{ errorMsg }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="errorMsg" class="captcha-error">{{ errorMsg }}</p>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.captcha-wrap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.captcha-row {
|
.captcha-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
height: 44px;
|
height: 36px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-row:focus-within {
|
||||||
|
border-color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hp-field {
|
.hp-field {
|
||||||
@@ -126,40 +161,33 @@ defineExpose({ validate, refresh });
|
|||||||
.captcha-input {
|
.captcha-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 14px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--border);
|
border: none;
|
||||||
border-right: none;
|
background: transparent;
|
||||||
border-radius: 8px 0 0 8px;
|
color: var(--text);
|
||||||
background: #1a1a1a;
|
font-size: 14px;
|
||||||
color: #fff;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
outline: none;
|
outline: none;
|
||||||
text-align: center;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-input::placeholder {
|
.captcha-input::placeholder {
|
||||||
color: var(--text-muted);
|
color: rgba(255, 255, 255, 0.32);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-input:focus {
|
|
||||||
border-color: var(--primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.captcha-canvas {
|
.captcha-canvas {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 108px;
|
width: 96px;
|
||||||
height: 44px;
|
height: 36px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 0 8px 8px 0;
|
border: none;
|
||||||
border: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
border-left: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-error {
|
.captcha-error {
|
||||||
|
|||||||
63
apps/player/src/composables/useSmsCode.ts
Normal file
63
apps/player/src/composables/useSmsCode.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const COOLDOWN_SECONDS = 60;
|
||||||
|
|
||||||
|
export function useSmsCode() {
|
||||||
|
const sessionId = ref<string | null>(null);
|
||||||
|
const countdown = ref(0);
|
||||||
|
const sending = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function clearTimer() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
clearTimer();
|
||||||
|
countdown.value = COOLDOWN_SECONDS;
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (countdown.value <= 1) {
|
||||||
|
clearTimer();
|
||||||
|
countdown.value = 0;
|
||||||
|
} else {
|
||||||
|
countdown.value -= 1;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(phone: string, countryCode: string) {
|
||||||
|
if (countdown.value > 0 || sending.value) return;
|
||||||
|
const trimmed = phone.trim();
|
||||||
|
const dial = countryCode.replace(/\D/g, '');
|
||||||
|
if (!trimmed || !dial) {
|
||||||
|
error.value = 'phone_required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sending.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const locale = localStorage.getItem('locale') || 'zh-CN';
|
||||||
|
const { data } = await api.post('/player/sms/send', {
|
||||||
|
phone: trimmed,
|
||||||
|
countryCode: dial,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
sessionId.value = data.data.sessionId;
|
||||||
|
startCountdown();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
|
error.value = msg || 'send_failed';
|
||||||
|
} finally {
|
||||||
|
sending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(clearTimer);
|
||||||
|
|
||||||
|
return { sessionId, countdown, sending, error, send };
|
||||||
|
}
|
||||||
@@ -93,9 +93,28 @@ const i18n = createI18n({
|
|||||||
register_failed: '注册失败,请重试',
|
register_failed: '注册失败,请重试',
|
||||||
continue_browsing: '暂不登录',
|
continue_browsing: '暂不登录',
|
||||||
username_placeholder: '请输入账号',
|
username_placeholder: '请输入账号',
|
||||||
|
login_account: '手机号 / 账号',
|
||||||
|
login_account_placeholder: '本地号码或账号',
|
||||||
|
login_username_placeholder: '手机号(含区号)或账号',
|
||||||
|
confirm_password: '确认密码',
|
||||||
|
password_mismatch: '两次密码不一致',
|
||||||
password_placeholder: '请输入密码',
|
password_placeholder: '请输入密码',
|
||||||
login_btn: '登录',
|
login_btn: '登录',
|
||||||
login_failed: '登录失败,请重试',
|
login_failed: '登录失败,请重试',
|
||||||
|
phone: '手机号',
|
||||||
|
phone_placeholder: '请输入手机号',
|
||||||
|
phone_local_placeholder: '请输入手机号',
|
||||||
|
phone_required: '请填写手机号',
|
||||||
|
phone_invalid: '手机号格式无效,请检查位数与号码',
|
||||||
|
phone_country_unsupported: '暂不支持该国家/地区',
|
||||||
|
sms_code: '短信验证码',
|
||||||
|
sms_code_placeholder: '6 位验证码',
|
||||||
|
sms_code_required: '请填写短信验证码',
|
||||||
|
sms_required: '请先获取短信验证码',
|
||||||
|
send_sms: '获取验证码',
|
||||||
|
resend_sms: '{sec}s 后重试',
|
||||||
|
country_search: '搜索国家或区号',
|
||||||
|
country_not_found: '未找到匹配国家',
|
||||||
},
|
},
|
||||||
support: {
|
support: {
|
||||||
short: '客服',
|
short: '客服',
|
||||||
@@ -476,9 +495,28 @@ const i18n = createI18n({
|
|||||||
register_failed: 'Registration failed, please try again',
|
register_failed: 'Registration failed, please try again',
|
||||||
continue_browsing: 'Skip login',
|
continue_browsing: 'Skip login',
|
||||||
username_placeholder: 'Enter username',
|
username_placeholder: 'Enter username',
|
||||||
|
login_account: 'Phone / Username',
|
||||||
|
login_account_placeholder: 'Local number or username',
|
||||||
|
login_username_placeholder: 'Registered phone (with country code) or username',
|
||||||
|
confirm_password: 'Confirm password',
|
||||||
|
password_mismatch: 'Passwords do not match',
|
||||||
password_placeholder: 'Enter password',
|
password_placeholder: 'Enter password',
|
||||||
login_btn: 'Log In',
|
login_btn: 'Log In',
|
||||||
login_failed: 'Login failed, please try again',
|
login_failed: 'Login failed, please try again',
|
||||||
|
phone: 'Phone',
|
||||||
|
phone_placeholder: 'Enter phone number',
|
||||||
|
phone_local_placeholder: 'Enter phone number',
|
||||||
|
phone_required: 'Phone number is required',
|
||||||
|
phone_invalid: 'Invalid phone number format',
|
||||||
|
phone_country_unsupported: 'This country or region is not supported',
|
||||||
|
sms_code: 'SMS Code',
|
||||||
|
sms_code_placeholder: '6-digit code',
|
||||||
|
sms_code_required: 'Please enter the SMS code',
|
||||||
|
sms_required: 'Please request an SMS code first',
|
||||||
|
send_sms: 'Get Code',
|
||||||
|
resend_sms: 'Retry in {sec}s',
|
||||||
|
country_search: 'Search country or code',
|
||||||
|
country_not_found: 'No matching country',
|
||||||
},
|
},
|
||||||
support: {
|
support: {
|
||||||
short: 'Support',
|
short: 'Support',
|
||||||
@@ -865,9 +903,28 @@ const i18n = createI18n({
|
|||||||
register_failed: 'Pendaftaran gagal, sila cuba lagi',
|
register_failed: 'Pendaftaran gagal, sila cuba lagi',
|
||||||
continue_browsing: 'Langkau log masuk',
|
continue_browsing: 'Langkau log masuk',
|
||||||
username_placeholder: 'Masukkan nama pengguna',
|
username_placeholder: 'Masukkan nama pengguna',
|
||||||
|
login_account: 'Telefon / Akaun',
|
||||||
|
login_account_placeholder: 'Nombor tempatan atau akaun',
|
||||||
|
login_username_placeholder: 'Telefon berdaftar (dengan kod negara) atau akaun',
|
||||||
|
confirm_password: 'Sahkan kata laluan',
|
||||||
|
password_mismatch: 'Kata laluan tidak sepadan',
|
||||||
password_placeholder: 'Masukkan kata laluan',
|
password_placeholder: 'Masukkan kata laluan',
|
||||||
login_btn: 'Log Masuk',
|
login_btn: 'Log Masuk',
|
||||||
login_failed: 'Log masuk gagal, sila cuba lagi',
|
login_failed: 'Log masuk gagal, sila cuba lagi',
|
||||||
|
phone: 'Telefon',
|
||||||
|
phone_placeholder: 'Masukkan nombor telefon',
|
||||||
|
phone_local_placeholder: 'Masukkan nombor telefon',
|
||||||
|
phone_required: 'Nombor telefon diperlukan',
|
||||||
|
phone_invalid: 'Format nombor telefon tidak sah',
|
||||||
|
phone_country_unsupported: 'Negara atau wilayah ini tidak disokong',
|
||||||
|
sms_code: 'Kod SMS',
|
||||||
|
sms_code_placeholder: 'Kod 6 digit',
|
||||||
|
sms_code_required: 'Sila masukkan kod SMS',
|
||||||
|
sms_required: 'Sila minta kod SMS dahulu',
|
||||||
|
send_sms: 'Dapatkan Kod',
|
||||||
|
resend_sms: 'Cuba lagi dalam {sec}s',
|
||||||
|
country_search: 'Cari negara atau kod',
|
||||||
|
country_not_found: 'Tiada negara sepadan',
|
||||||
},
|
},
|
||||||
support: {
|
support: {
|
||||||
short: 'Sokongan',
|
short: 'Sokongan',
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
loginPromptVisible.value = false;
|
loginPromptVisible.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(username: string, password: string) {
|
async function login(username: string, password: string, countryCode?: string) {
|
||||||
const { data } = await api.post('/player/auth/login', { username, password });
|
const dial = countryCode?.replace(/\D/g, '');
|
||||||
|
const { data } = await api.post('/player/auth/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
...(dial ? { countryCode: dial } : {}),
|
||||||
|
});
|
||||||
token.value = data.data.token;
|
token.value = data.data.token;
|
||||||
user.value = data.data.user;
|
user.value = data.data.user;
|
||||||
localStorage.setItem('token', token.value);
|
localStorage.setItem('token', token.value);
|
||||||
@@ -32,12 +37,23 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return returnTo;
|
return returnTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(username: string, password: string, inviteCode?: string) {
|
async function register(
|
||||||
|
phone: string,
|
||||||
|
countryCode: string,
|
||||||
|
password: string,
|
||||||
|
smsCode: string,
|
||||||
|
sessionId: string,
|
||||||
|
inviteCode?: string,
|
||||||
|
) {
|
||||||
const locale = localStorage.getItem('locale') || 'zh-CN';
|
const locale = localStorage.getItem('locale') || 'zh-CN';
|
||||||
const code = inviteCode?.trim();
|
const code = inviteCode?.trim();
|
||||||
|
const dial = countryCode.replace(/\D/g, '');
|
||||||
const { data } = await api.post('/player/auth/register', {
|
const { data } = await api.post('/player/auth/register', {
|
||||||
username,
|
phone,
|
||||||
|
countryCode: dial,
|
||||||
password,
|
password,
|
||||||
|
smsCode,
|
||||||
|
sessionId,
|
||||||
locale,
|
locale,
|
||||||
...(code ? { inviteCode: code } : {}),
|
...(code ? { inviteCode: code } : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,11 @@
|
|||||||
background: #0d0d0d !important;
|
background: #0d0d0d !important;
|
||||||
border: 1px solid var(--border) !important;
|
border: 1px solid var(--border) !important;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-gold-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-gold-input:focus {
|
.ps-gold-input:focus {
|
||||||
|
|||||||
@@ -66,10 +66,20 @@ function goRegister() {
|
|||||||
<LocaleSwitcher compact />
|
<LocaleSwitcher compact />
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
||||||
<label>{{ t('auth.username') }}</label>
|
<div class="field">
|
||||||
<input v-model="username" class="ps-gold-input" required />
|
<label>{{ t('auth.username') }}</label>
|
||||||
<label>{{ t('auth.password') }}</label>
|
<input
|
||||||
<input v-model="password" class="ps-gold-input" type="password" required />
|
v-model="username"
|
||||||
|
class="field-input"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
:placeholder="t('auth.login_username_placeholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('auth.password') }}</label>
|
||||||
|
<input v-model="password" class="field-input" type="password" required />
|
||||||
|
</div>
|
||||||
<RobotVerify ref="captchaRef" />
|
<RobotVerify ref="captchaRef" />
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
|
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
|
||||||
@@ -111,11 +121,45 @@ function goRegister() {
|
|||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 340px;
|
max-width: 320px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-row .field-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
|
|||||||
@@ -2,34 +2,59 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { defaultPhoneIsoForLocale, getPhoneDialFromIso } from '@thebet365/shared';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useAppLocale } from '../composables/useAppLocale';
|
import { useAppLocale } from '../composables/useAppLocale';
|
||||||
|
import { useSmsCode } from '../composables/useSmsCode';
|
||||||
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
||||||
import RobotVerify from '../components/RobotVerify.vue';
|
import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
|
||||||
import loginBg from '../assets/images/h5bg.png';
|
import loginBg from '../assets/images/h5bg.png';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { initFromUser } = useAppLocale();
|
const { initFromUser } = useAppLocale();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
|
|
||||||
const username = ref('');
|
const phone = ref('');
|
||||||
|
const countryIso = ref(defaultPhoneIsoForLocale(locale.value));
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
const smsCode = ref('');
|
||||||
const inviteCode = ref(typeof route.query.code === 'string' ? route.query.code : '');
|
const inviteCode = ref(typeof route.query.code === 'string' ? route.query.code : '');
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const { sessionId, countdown, sending, error: smsError, send } = useSmsCode();
|
||||||
|
|
||||||
|
async function sendCode() {
|
||||||
|
await send(phone.value, getPhoneDialFromIso(countryIso.value));
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!captchaRef.value?.validate()) {
|
if (!sessionId.value) {
|
||||||
error.value = t('auth.captcha_wrong');
|
error.value = t('auth.sms_required');
|
||||||
captchaRef.value?.refresh();
|
return;
|
||||||
|
}
|
||||||
|
if (!smsCode.value.trim()) {
|
||||||
|
error.value = t('auth.sms_code_required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
error.value = t('auth.password_mismatch');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
await auth.register(username.value, password.value, inviteCode.value);
|
await auth.register(
|
||||||
|
phone.value,
|
||||||
|
getPhoneDialFromIso(countryIso.value),
|
||||||
|
password.value,
|
||||||
|
smsCode.value,
|
||||||
|
sessionId.value,
|
||||||
|
inviteCode.value,
|
||||||
|
);
|
||||||
initFromUser(auth.user?.locale);
|
initFromUser(auth.user?.locale);
|
||||||
const redirectTo = (route.query.redirect as string) || '/';
|
const redirectTo = (route.query.redirect as string) || '/';
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
@@ -43,6 +68,25 @@ async function submit() {
|
|||||||
function goLogin() {
|
function goLogin() {
|
||||||
router.push({ path: '/login', query: route.query.redirect ? { redirect: route.query.redirect as string } : {} });
|
router.push({ path: '/login', query: route.query.redirect ? { redirect: route.query.redirect as string } : {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGuestBrowsablePath(path: string): boolean {
|
||||||
|
if (!path || path === '/' || path === '/bet') return true;
|
||||||
|
if (path.startsWith('/match/')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function continueBrowsing() {
|
||||||
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '';
|
||||||
|
const target = isGuestBrowsablePath(redirect) ? redirect || '/' : '/';
|
||||||
|
router.replace(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldError = () => {
|
||||||
|
if (error.value) return error.value;
|
||||||
|
if (smsError.value === 'phone_required') return t('auth.phone_required');
|
||||||
|
if (smsError.value) return smsError.value;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -52,14 +96,61 @@ function goLogin() {
|
|||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
||||||
<h2 class="form-title">{{ t('auth.register') }}</h2>
|
<h2 class="form-title">{{ t('auth.register') }}</h2>
|
||||||
<label>{{ t('auth.invite_code') }} <span class="optional-tag">{{ t('auth.optional') }}</span></label>
|
|
||||||
<input v-model="inviteCode" class="ps-gold-input" autocomplete="off" />
|
<div class="field">
|
||||||
<label>{{ t('auth.username') }}</label>
|
<label>{{ t('auth.phone') }}</label>
|
||||||
<input v-model="username" class="ps-gold-input" required autocomplete="username" />
|
<div class="inline-row">
|
||||||
<label>{{ t('auth.password') }}</label>
|
<PhoneCountrySelect v-model="countryIso" />
|
||||||
<input v-model="password" class="ps-gold-input" type="password" required autocomplete="new-password" minlength="8" />
|
<input
|
||||||
<RobotVerify ref="captchaRef" />
|
v-model="phone"
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
class="field-input"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
autocomplete="tel-national"
|
||||||
|
:placeholder="t('auth.phone_local_placeholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('auth.sms_code') }}</label>
|
||||||
|
<div class="inline-row">
|
||||||
|
<input
|
||||||
|
v-model="smsCode"
|
||||||
|
class="field-input"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
:placeholder="t('auth.sms_code_placeholder')"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="sending || countdown > 0 || !phone.trim()"
|
||||||
|
@click="sendCode"
|
||||||
|
>
|
||||||
|
{{ countdown > 0 ? `${countdown}s` : t('auth.send_sms') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('auth.password') }}</label>
|
||||||
|
<input v-model="password" class="field-input" type="password" required autocomplete="new-password" minlength="8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('auth.confirm_password') }}</label>
|
||||||
|
<input v-model="confirmPassword" class="field-input" type="password" required autocomplete="new-password" minlength="8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field-optional">
|
||||||
|
<label>{{ t('auth.invite_code') }} <span class="optional-tag">{{ t('auth.optional') }}</span></label>
|
||||||
|
<input v-model="inviteCode" class="field-input" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="fieldError()" class="error">{{ fieldError() }}</p>
|
||||||
|
|
||||||
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
|
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
|
||||||
{{ t('auth.register_btn') }}
|
{{ t('auth.register_btn') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -67,6 +158,9 @@ function goLogin() {
|
|||||||
{{ t('auth.have_account') }}
|
{{ t('auth.have_account') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<button type="button" class="btn-skip-light" @click="continueBrowsing">
|
||||||
|
{{ t('auth.continue_browsing') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -87,7 +181,7 @@ function goLogin() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 28vh 20px calc(12vh + max(28px, env(safe-area-inset-bottom)));
|
padding: 30vh 16px calc(10vh + max(20px, env(safe-area-inset-bottom)));
|
||||||
background-color: var(--tertiary);
|
background-color: var(--tertiary);
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center top;
|
background-position: center top;
|
||||||
@@ -96,41 +190,101 @@ function goLogin() {
|
|||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 340px;
|
max-width: 320px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-title {
|
.form-title {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 2px;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.optional-tag {
|
.field {
|
||||||
font-size: 10px;
|
display: flex;
|
||||||
font-weight: 500;
|
flex-direction: column;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-optional {
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-row .field-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #141414;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-login {
|
.btn-login {
|
||||||
margin-top: 4px;
|
margin-top: 2px;
|
||||||
padding: 10px 14px;
|
padding: 9px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-login:disabled {
|
.btn-login:disabled {
|
||||||
@@ -153,9 +307,30 @@ label {
|
|||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.btn-skip-light {
|
||||||
color: var(--danger);
|
position: absolute;
|
||||||
|
bottom: calc(20px + env(safe-area-inset-bottom));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-skip-light:active {
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
UPLOAD_DIR: /app/uploads
|
UPLOAD_DIR: /app/uploads
|
||||||
SEED_DATABASE: ${SEED_DATABASE:-false}
|
SEED_DATABASE: ${SEED_DATABASE:-false}
|
||||||
|
CHUANGLAN_ACCOUNT: ${CHUANGLAN_ACCOUNT}
|
||||||
|
CHUANGLAN_PASSWORD: ${CHUANGLAN_PASSWORD}
|
||||||
|
CHUANGLAN_ENDPOINT: ${CHUANGLAN_ENDPOINT:-https://sgap.253.com/send/sms}
|
||||||
|
CHUANGLAN_CONNECT_TIMEOUT_MS: ${CHUANGLAN_CONNECT_TIMEOUT_MS:-10000}
|
||||||
|
CHUANGLAN_READ_TIMEOUT_MS: ${CHUANGLAN_READ_TIMEOUT_MS:-10000}
|
||||||
|
SMS_CODE_TTL_SECONDS: ${SMS_CODE_TTL_SECONDS:-300}
|
||||||
|
SMS_RATE_LIMIT_SECONDS: ${SMS_RATE_LIMIT_SECONDS:-60}
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"generate:phone-countries": "node scripts/generate-phone-countries.mjs",
|
||||||
|
"build": "pnpm run generate:phone-countries && tsc",
|
||||||
"dev": "tsc --watch"
|
"dev": "tsc --watch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"country-codes-list": "^2.0.0",
|
||||||
"typescript": "^5.7.3"
|
"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',
|
'en-US': 'Payment method is required',
|
||||||
'ms-MY': 'Kaedah pembayaran diperlukan',
|
'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>>;
|
} as const satisfies Record<string, Record<Locale, string>>;
|
||||||
|
|
||||||
export type ApiErrorCode = keyof typeof API_ERROR_MESSAGES;
|
export type ApiErrorCode = keyof typeof API_ERROR_MESSAGES;
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export * from './builtinPlayers';
|
|||||||
export * from './playerLocale';
|
export * from './playerLocale';
|
||||||
export * from './playerUsername';
|
export * from './playerUsername';
|
||||||
export * from './initial-depositRemark';
|
export * from './initial-depositRemark';
|
||||||
|
export * from './phone-countries';
|
||||||
|
|
||||||
export interface ApiResponse<T = unknown> {
|
export interface ApiResponse<T = unknown> {
|
||||||
success: boolean;
|
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",
|
"rootDir": "./src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"]
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -193,6 +193,9 @@ importers:
|
|||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
country-codes-list:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.7.3
|
version: 5.7.3
|
||||||
@@ -1938,6 +1941,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==}
|
resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
country-codes-list@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KZqq/LBdCD76hQCa6nOx0bA/nIjYly1OtV8+Bbt/4SW+mJEqGk7oZHjUj7PRrV0gXJJKs6Tv2cIntFdofBByvA==}
|
||||||
|
|
||||||
create-jest@29.7.0:
|
create-jest@29.7.0:
|
||||||
resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==}
|
resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
@@ -5615,6 +5621,8 @@ snapshots:
|
|||||||
parse-json: 5.2.0
|
parse-json: 5.2.0
|
||||||
path-type: 4.0.0
|
path-type: 4.0.0
|
||||||
|
|
||||||
|
country-codes-list@2.0.0: {}
|
||||||
|
|
||||||
create-jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.7.3)):
|
create-jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.7.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
|
|||||||
Reference in New Issue
Block a user