feat(auth): 切换注册方式为手机验证码并添加客服功能
- 将注册表单从用户名改为手机号输入 - 集成短信验证码功能,添加验证码输入字段 - 实现发送短信验证码的API调用和倒计时逻辑 - 更新国际化配置以支持验证码相关文案 - 添加桌面端客服聊天弹窗功能 - 调整注册表单UI布局和样式优化 - 修改认证相关常量和类型定义以适配新流程
This commit is contained in:
@@ -8,17 +8,11 @@ export const AUTH_ENDPOINTS = {
|
||||
profile: 'api/user/profile',
|
||||
refreshToken: 'api/user/refreshToken',
|
||||
register: 'api/user/register',
|
||||
sendSmsCode: 'api/user/sendSmsCode',
|
||||
} as const
|
||||
|
||||
/** @description 后端返回该 code 表示登录态 token 无效或已过期。 */
|
||||
export const AUTH_INVALID_TOKEN_CODE = 1101
|
||||
|
||||
/** @description 后端返回这些 code 时需要清空当前状态并触发用户重新登录。 */
|
||||
export const AUTH_RELOGIN_REQUIRED_CODES: readonly number[] = [
|
||||
AUTH_INVALID_TOKEN_CODE,
|
||||
1103,
|
||||
303,
|
||||
]
|
||||
export const AUTH_RELOGIN_REQUIRED_CODES: readonly number[] = [1101, 1103, 303]
|
||||
|
||||
/** @description 获取接口鉴权 auth-token 时使用的接口地址。 */
|
||||
export const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
|
||||
|
||||
@@ -113,6 +113,7 @@ export const MODAL_KEYS = [
|
||||
'desktopProcedures',
|
||||
'desktopWithdrawTopup',
|
||||
'desktopPeriodHistory',
|
||||
'desktopSupport',
|
||||
] as const
|
||||
|
||||
/** @description 全局弹窗默认可见状态。 */
|
||||
@@ -127,4 +128,5 @@ export const INITIAL_MODAL_VISIBILITY = {
|
||||
desktopProcedures: false,
|
||||
desktopWithdrawTopup: false,
|
||||
desktopPeriodHistory: false,
|
||||
desktopSupport: false,
|
||||
} as const
|
||||
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
RefreshTokenRequestDto,
|
||||
RegisterPayload,
|
||||
RegisterRequestDto,
|
||||
SendSmsCodeDto,
|
||||
SendSmsCodePayload,
|
||||
SendSmsCodeRequestDto,
|
||||
SendSmsCodeResult,
|
||||
} from './types'
|
||||
import {
|
||||
mergeAuthUsers,
|
||||
@@ -140,10 +144,11 @@ export async function registerWithPassword(
|
||||
AUTH_ENDPOINTS.register,
|
||||
{
|
||||
json: {
|
||||
captcha: payload.captcha,
|
||||
device_id: getAuthDeviceId(),
|
||||
invite_code: payload.inviteCode,
|
||||
password: payload.password,
|
||||
username: payload.username,
|
||||
username: payload.mobile,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -160,6 +165,30 @@ export async function registerWithPassword(
|
||||
return session
|
||||
}
|
||||
|
||||
export async function sendSmsCode(
|
||||
payload: SendSmsCodePayload,
|
||||
): Promise<SendSmsCodeResult> {
|
||||
const response = await api.post<SendSmsCodeDto, SendSmsCodeRequestDto>(
|
||||
AUTH_ENDPOINTS.sendSmsCode,
|
||||
{
|
||||
json: {
|
||||
event: 'user_register',
|
||||
mobile: payload.mobile,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const data = unwrapEnvelope(
|
||||
response as ApiResponse<SendSmsCodeDto>,
|
||||
'auth.register.sms.errors.submitFailed',
|
||||
)
|
||||
|
||||
return {
|
||||
expiresIn: data.expires_in,
|
||||
messageId: data.message_id,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentUserProfile() {
|
||||
const response = await api.post<AuthUserProfileDto>(AUTH_ENDPOINTS.profile)
|
||||
|
||||
|
||||
@@ -61,8 +61,22 @@ export interface LogoutRequestDto {
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface RegisterRequestDto extends LoginRequestDto {
|
||||
export interface RegisterRequestDto {
|
||||
captcha: string
|
||||
device_id?: string
|
||||
invite_code: string
|
||||
password: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface SendSmsCodeRequestDto {
|
||||
event: 'user_register'
|
||||
mobile: string
|
||||
}
|
||||
|
||||
export interface SendSmsCodeDto {
|
||||
expires_in: number
|
||||
message_id: string
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequestDto {
|
||||
@@ -76,8 +90,20 @@ export interface LoginPayload {
|
||||
|
||||
export type LogoutPayload = LoginPayload
|
||||
|
||||
export interface RegisterPayload extends LoginPayload {
|
||||
export interface RegisterPayload {
|
||||
captcha: string
|
||||
inviteCode: string
|
||||
mobile: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface SendSmsCodePayload {
|
||||
mobile: string
|
||||
}
|
||||
|
||||
export interface SendSmsCodeResult {
|
||||
expiresIn: number
|
||||
messageId: string
|
||||
}
|
||||
|
||||
export function normalizeAuthUser(dto: AuthUserDto): AuthUser {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import loginBg from '@/assets/system/login-bg.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
DesktopAuthFieldRow,
|
||||
DesktopAuthInputError,
|
||||
@@ -11,40 +12,56 @@ import {
|
||||
|
||||
interface DesktopRegisterFormViewProps {
|
||||
errors: {
|
||||
captcha?: string
|
||||
confirmPassword?: string
|
||||
inviteCode?: string
|
||||
mobile?: string
|
||||
password?: string
|
||||
username?: string
|
||||
}
|
||||
captcha: string
|
||||
inviteCode: string
|
||||
isSendingSmsCode: boolean
|
||||
isSubmitting: boolean
|
||||
mobile: string
|
||||
onCaptchaChange: (value: string) => void
|
||||
onConfirmPasswordChange: (value: string) => void
|
||||
onInviteCodeChange: (value: string) => void
|
||||
onMobileChange: (value: string) => void
|
||||
onPasswordChange: (value: string) => void
|
||||
onSendSmsCode: () => Promise<unknown>
|
||||
onSubmit: () => void
|
||||
onSwitchToLogin: () => void
|
||||
onUsernameChange: (value: string) => void
|
||||
smsCodeCanSend: boolean
|
||||
smsCodeRemainingSeconds: number
|
||||
password: string
|
||||
confirmPassword: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export function DesktopRegisterFormView({
|
||||
captcha,
|
||||
confirmPassword,
|
||||
errors,
|
||||
inviteCode,
|
||||
isSendingSmsCode,
|
||||
isSubmitting,
|
||||
mobile,
|
||||
onCaptchaChange,
|
||||
onConfirmPasswordChange,
|
||||
onInviteCodeChange,
|
||||
onMobileChange,
|
||||
onPasswordChange,
|
||||
onSendSmsCode,
|
||||
onSubmit,
|
||||
onSwitchToLogin,
|
||||
onUsernameChange,
|
||||
smsCodeCanSend,
|
||||
smsCodeRemainingSeconds,
|
||||
password,
|
||||
username,
|
||||
}: DesktopRegisterFormViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const compactInputClassName =
|
||||
'h-design-54 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] py-design-11 text-left text-design-21 shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]'
|
||||
const compactErrorClassName = 'relative h-design-26 overflow-hidden'
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -53,7 +70,7 @@ export function DesktopRegisterFormView({
|
||||
onSubmit()
|
||||
}}
|
||||
className={
|
||||
'relative isolate flex flex-col px-design-30 mt-design-10 box-border h-design-700 !overflow-hidden'
|
||||
'relative isolate mt-design-8 box-border flex h-design-700 flex-col px-design-30 !overflow-hidden'
|
||||
}
|
||||
>
|
||||
<div
|
||||
@@ -63,34 +80,78 @@ export function DesktopRegisterFormView({
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(93,211,218,0.16),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(63,109,137,0.22),transparent_34%)]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-design-140 bg-[linear-gradient(180deg,rgba(87,196,201,0.12),transparent)]" />
|
||||
<div className="relative flex flex-col gap-design-20 p-design-50">
|
||||
<div className="relative flex flex-col gap-design-12 p-design-38">
|
||||
<div className="flex items-center gap-design-14">
|
||||
<div className="h-design-10 w-design-10 rounded-full bg-[#6EE4E6] shadow-[0_0_calc(var(--design-unit)*12)_rgba(110,228,230,0.75)]" />
|
||||
<div className="h-px flex-1 bg-[linear-gradient(90deg,rgba(110,228,230,0.5),rgba(110,228,230,0))]" />
|
||||
</div>
|
||||
|
||||
<DesktopAuthFieldRow label={t('auth.register.fields.username.label')}>
|
||||
<DesktopAuthFieldRow label={t('auth.register.fields.mobile.label')}>
|
||||
<Input
|
||||
id="desktop-register-username"
|
||||
name="username"
|
||||
id="desktop-register-mobile"
|
||||
name="mobile"
|
||||
autoComplete="username"
|
||||
inputMode="tel"
|
||||
spellCheck={false}
|
||||
value={username}
|
||||
onChange={(event) => onUsernameChange(event.target.value)}
|
||||
placeholder={t('auth.register.fields.username.placeholder')}
|
||||
aria-describedby="desktop-register-username-error"
|
||||
aria-invalid={Boolean(errors.username)}
|
||||
className={
|
||||
'h-design-58 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] text-left shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]'
|
||||
}
|
||||
value={mobile}
|
||||
onChange={(event) => onMobileChange(event.target.value)}
|
||||
placeholder={t('auth.register.fields.mobile.placeholder')}
|
||||
aria-describedby="desktop-register-mobile-error"
|
||||
aria-invalid={Boolean(errors.mobile)}
|
||||
className={compactInputClassName}
|
||||
/>
|
||||
<div
|
||||
id="desktop-register-username-error"
|
||||
className="relative h-design-30 overflow-hidden"
|
||||
id="desktop-register-mobile-error"
|
||||
className={compactErrorClassName}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
message={errors.username ? t(errors.username) : undefined}
|
||||
message={errors.mobile ? t(errors.mobile) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DesktopAuthFieldRow>
|
||||
|
||||
<DesktopAuthFieldRow label={t('auth.register.fields.captcha.label')}>
|
||||
<div className="flex gap-design-12">
|
||||
<Input
|
||||
id="desktop-register-captcha"
|
||||
name="captcha"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
spellCheck={false}
|
||||
value={captcha}
|
||||
onChange={(event) => onCaptchaChange(event.target.value)}
|
||||
placeholder={t('auth.register.fields.captcha.placeholder')}
|
||||
aria-describedby="desktop-register-captcha-error"
|
||||
aria-invalid={Boolean(errors.captcha)}
|
||||
className={compactInputClassName}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onSendSmsCode()}
|
||||
disabled={!mobile.trim() || !smsCodeCanSend}
|
||||
className={cn(
|
||||
'h-design-54 w-design-166 shrink-0 cursor-pointer rounded-md border border-[#3F8E93] bg-[linear-gradient(180deg,rgba(37,116,122,0.9),rgba(16,75,82,0.9))] px-design-12 text-design-19 font-semibold text-[#E8FFFF] transition duration-200 ease-out hover:brightness-110 focus-visible:outline-none focus-visible:ring-[calc(var(--design-unit)*1.5)] focus-visible:ring-[rgba(110,255,255,0.35)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-55',
|
||||
smsCodeRemainingSeconds > 0 && 'text-[#9AC7CA]',
|
||||
)}
|
||||
>
|
||||
{smsCodeRemainingSeconds > 0
|
||||
? t('auth.register.sms.countdown', {
|
||||
seconds: smsCodeRemainingSeconds,
|
||||
})
|
||||
: isSendingSmsCode
|
||||
? t('auth.register.sms.sending')
|
||||
: t('auth.register.sms.send')}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="desktop-register-captcha-error"
|
||||
className={compactErrorClassName}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
message={errors.captcha ? t(errors.captcha) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,13 +167,11 @@ export function DesktopRegisterFormView({
|
||||
placeholder={t('auth.register.fields.password.placeholder')}
|
||||
aria-describedby="desktop-register-password-error"
|
||||
aria-invalid={Boolean(errors.password)}
|
||||
className={
|
||||
'h-design-58 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] text-left shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]'
|
||||
}
|
||||
className={compactInputClassName}
|
||||
/>
|
||||
<div
|
||||
id="desktop-register-password-error"
|
||||
className="relative h-design-30 overflow-hidden"
|
||||
className={compactErrorClassName}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
@@ -136,13 +195,11 @@ export function DesktopRegisterFormView({
|
||||
)}
|
||||
aria-describedby="desktop-register-confirm-password-error"
|
||||
aria-invalid={Boolean(errors.confirmPassword)}
|
||||
className={
|
||||
'h-design-58 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] text-left shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]'
|
||||
}
|
||||
className={compactInputClassName}
|
||||
/>
|
||||
<div
|
||||
id="desktop-register-confirm-password-error"
|
||||
className="relative h-design-30 overflow-hidden"
|
||||
className={compactErrorClassName}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
@@ -170,13 +227,11 @@ export function DesktopRegisterFormView({
|
||||
placeholder={t('auth.register.fields.inviteCode.placeholder')}
|
||||
aria-describedby="desktop-register-invite-code-error"
|
||||
aria-invalid={Boolean(errors.inviteCode)}
|
||||
className={
|
||||
'h-design-58 max-w-design-520 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] text-left shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]'
|
||||
}
|
||||
className={cn(compactInputClassName, 'max-w-design-520')}
|
||||
/>
|
||||
<div
|
||||
id="desktop-register-invite-code-error"
|
||||
className="relative h-design-30 overflow-hidden"
|
||||
className={compactErrorClassName}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
@@ -189,7 +244,7 @@ export function DesktopRegisterFormView({
|
||||
<div className="mt-auto flex flex-col">
|
||||
<motion.div
|
||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
|
||||
className="flex items-center justify-center gap-design-12 text-center text-design-20 text-[#6DB5B9]"
|
||||
className="flex items-center justify-center gap-design-12 text-center text-design-18 text-[#6DB5B9]"
|
||||
>
|
||||
<div className="h-px w-design-90 bg-[linear-gradient(90deg,rgba(109,181,185,0),rgba(109,181,185,0.7))]" />
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useController } from 'react-hook-form'
|
||||
import { useModalStore } from '@/store'
|
||||
import { useRegisterForm } from '../hooks/use-register-form'
|
||||
import { useSendSmsCode } from '../hooks/use-send-sms-code'
|
||||
import { DesktopRegisterFormView } from './desktop-register-form-view'
|
||||
|
||||
interface DesktopRegisterFormProps {
|
||||
@@ -11,10 +12,15 @@ export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) {
|
||||
const { form, isSubmitting, onSubmit } = useRegisterForm({
|
||||
onSuccess,
|
||||
})
|
||||
const smsCode = useSendSmsCode()
|
||||
const openExclusiveModal = useModalStore((state) => state.openExclusiveModal)
|
||||
const usernameField = useController({
|
||||
const mobileField = useController({
|
||||
control: form.control,
|
||||
name: 'username',
|
||||
name: 'mobile',
|
||||
})
|
||||
const captchaField = useController({
|
||||
control: form.control,
|
||||
name: 'captcha',
|
||||
})
|
||||
const passwordField = useController({
|
||||
control: form.control,
|
||||
@@ -35,23 +41,30 @@ export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) {
|
||||
|
||||
return (
|
||||
<DesktopRegisterFormView
|
||||
username={usernameField.field.value ?? ''}
|
||||
mobile={mobileField.field.value ?? ''}
|
||||
captcha={captchaField.field.value ?? ''}
|
||||
password={passwordField.field.value ?? ''}
|
||||
confirmPassword={confirmPasswordField.field.value ?? ''}
|
||||
inviteCode={inviteCodeField.field.value ?? ''}
|
||||
errors={{
|
||||
captcha: form.formState.errors.captcha?.message,
|
||||
confirmPassword: form.formState.errors.confirmPassword?.message,
|
||||
inviteCode: form.formState.errors.inviteCode?.message,
|
||||
mobile: form.formState.errors.mobile?.message,
|
||||
password: form.formState.errors.password?.message,
|
||||
username: form.formState.errors.username?.message,
|
||||
}}
|
||||
isSubmitting={isSubmitting}
|
||||
isSendingSmsCode={smsCode.isSending}
|
||||
onConfirmPasswordChange={confirmPasswordField.field.onChange}
|
||||
onCaptchaChange={captchaField.field.onChange}
|
||||
onInviteCodeChange={inviteCodeField.field.onChange}
|
||||
onSendSmsCode={() => smsCode.send(mobileField.field.value ?? '')}
|
||||
onPasswordChange={passwordField.field.onChange}
|
||||
onSubmit={onSubmit}
|
||||
onSwitchToLogin={handleSwitchToLogin}
|
||||
onUsernameChange={usernameField.field.onChange}
|
||||
onMobileChange={mobileField.field.onChange}
|
||||
smsCodeCanSend={smsCode.canSend}
|
||||
smsCodeRemainingSeconds={smsCode.remainingSeconds}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,10 +34,11 @@ export function useRegisterForm({ onSuccess }: UseRegisterFormOptions = {}) {
|
||||
const startSession = useAuthStore((state) => state.startSession)
|
||||
const form = useForm<RegisterFormValues>({
|
||||
defaultValues: {
|
||||
captcha: '',
|
||||
confirmPassword: '',
|
||||
inviteCode: getInitialRegisterInviteCode(),
|
||||
mobile: '',
|
||||
password: '',
|
||||
username: '',
|
||||
},
|
||||
mode: 'onBlur',
|
||||
resolver: createZodResolver(registerFormSchema),
|
||||
|
||||
51
src/features/auth/hooks/use-send-sms-code.ts
Normal file
51
src/features/auth/hooks/use-send-sms-code.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import i18n from '@/i18n'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { sendSmsCode } from '../api/auth-api'
|
||||
import { toAuthSubmitErrorKey } from './auth-error-key'
|
||||
|
||||
const FALLBACK_SMS_CODE_COOLDOWN_SECONDS = 60
|
||||
|
||||
export function useSendSmsCode() {
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(0)
|
||||
const mutation = useMutation({
|
||||
mutationFn: (mobile: string) => sendSmsCode({ mobile: mobile.trim() }),
|
||||
onError: (error) => {
|
||||
const errorKey = toAuthSubmitErrorKey(error, 'register')
|
||||
|
||||
notify.error(
|
||||
errorKey
|
||||
? i18n.t(errorKey)
|
||||
: i18n.t('auth.register.sms.errors.submitFailed'),
|
||||
)
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setRemainingSeconds(
|
||||
result.expiresIn > 0
|
||||
? result.expiresIn
|
||||
: FALLBACK_SMS_CODE_COOLDOWN_SECONDS,
|
||||
)
|
||||
notify.success(i18n.t('auth.register.sms.success'))
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingSeconds <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setRemainingSeconds((value) => Math.max(0, value - 1))
|
||||
}, 1000)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [remainingSeconds])
|
||||
|
||||
return {
|
||||
canSend: !mutation.isPending && remainingSeconds <= 0,
|
||||
isSending: mutation.isPending,
|
||||
remainingSeconds,
|
||||
send: mutation.mutateAsync,
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@ const usernameSchema = z
|
||||
.trim()
|
||||
.min(1, 'auth.validation.username.required')
|
||||
|
||||
const captchaSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'auth.validation.captcha.required')
|
||||
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(6, 'auth.validation.password.min')
|
||||
@@ -17,6 +22,7 @@ export const loginFormSchema = z.object({
|
||||
|
||||
export const registerFormSchema = z
|
||||
.object({
|
||||
captcha: captchaSchema,
|
||||
confirmPassword: passwordSchema,
|
||||
inviteCode: z
|
||||
.string()
|
||||
@@ -24,7 +30,7 @@ export const registerFormSchema = z
|
||||
.min(1, 'auth.validation.inviteCode.required')
|
||||
.max(32, 'auth.validation.inviteCode.max'),
|
||||
password: passwordSchema,
|
||||
username: usernameSchema,
|
||||
mobile: usernameSchema,
|
||||
})
|
||||
.refine((value) => value.password === value.confirmPassword, {
|
||||
message: 'auth.validation.confirmPassword.mismatch',
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import add from '@/assets/game/add.webp'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import chatImage from '@/assets/system/chat.webp'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import logo from '@/assets/system/logo.webp'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
useHeaderClockLabel,
|
||||
useHeaderVm,
|
||||
} from '@/features/game/hooks/use-header-vm'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function HeaderClock() {
|
||||
const systemTimeLabel = useHeaderClockLabel()
|
||||
@@ -59,6 +62,7 @@ function SignalBars({
|
||||
|
||||
export function DesktopHeader() {
|
||||
const { t } = useTranslation()
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const {
|
||||
authStatus,
|
||||
currentLanguageLabel,
|
||||
@@ -173,6 +177,21 @@ export function DesktopHeader() {
|
||||
)}
|
||||
<div>{t('gameDesktop.header.fullscreen')}</div>
|
||||
</button>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => setModalOpen('desktopSupport', true)}
|
||||
whileTap={{
|
||||
scale: 0.95,
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<SmartImage
|
||||
className={'h-design-30 w-design-30 cursor-pointer'}
|
||||
alt={'chatImage'}
|
||||
src={chatImage}
|
||||
/>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{authStatus === 'authenticated' ? (
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DesktopPeriodHistoryDrawer } from '@/features/game/modal/desktop/deskto
|
||||
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
|
||||
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
|
||||
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
|
||||
import DesktopSupportModal from '@/features/game/modal/desktop/desktop-support-modal.tsx'
|
||||
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
|
||||
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
||||
import { useDocumentMetadata } from '@/lib/head/document-metadata'
|
||||
@@ -42,6 +43,8 @@ function EntryModalHost() {
|
||||
<DesktopProceduresModal />
|
||||
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
|
||||
<DesktopWithdrawTopupModal />
|
||||
{/* 桌面端客服弹窗:承载在线客服 iframe */}
|
||||
<DesktopSupportModal />
|
||||
{/* 强制弹窗 */}
|
||||
<EntryNoticeGateModal />
|
||||
{/* 历史开奖信息弹窗 */}
|
||||
|
||||
75
src/features/game/modal/desktop/desktop-support-modal.tsx
Normal file
75
src/features/game/modal/desktop/desktop-support-modal.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
const SUPPORT_CHAT_URL =
|
||||
'https://tawk.to/chat/6a1d23d9e29f411c2ce86772/1jq0t82lu'
|
||||
const IFRAME_READY_DELAY_MS = 2_000
|
||||
|
||||
function DesktopSupportModal() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const readyTimerRef = useRef<number | null>(null)
|
||||
const open = useModalStore((state) => state.modals.desktopSupport)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
const clearReadyTimer = useCallback(() => {
|
||||
if (readyTimerRef.current === null) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(readyTimerRef.current)
|
||||
readyTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopSupport', false)
|
||||
}
|
||||
|
||||
const handleLoaded = () => {
|
||||
clearReadyTimer()
|
||||
readyTimerRef.current = window.setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
readyTimerRef.current = null
|
||||
}, IFRAME_READY_DELAY_MS)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
clearReadyTimer()
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
return clearReadyTimer
|
||||
}, [clearReadyTimer, open])
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
isNormalBg={true}
|
||||
onClose={handleClose}
|
||||
titleAlign="left"
|
||||
title={<div className="modal-title-glow text-design-30">在线客服</div>}
|
||||
className="h-design-760 w-design-980"
|
||||
>
|
||||
<div className="h-full px-design-24 pb-design-40 pt-design-10">
|
||||
<div className="relative h-full overflow-hidden rounded-[calc(var(--design-unit)*14)] border border-[#2A6D73] bg-[linear-gradient(180deg,rgba(5,22,31,0.98),rgba(2,10,17,0.98))] shadow-[inset_0_0_calc(var(--design-unit)*22)_rgba(88,205,218,0.13),0_0_calc(var(--design-unit)*18)_rgba(31,156,174,0.14)]">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
|
||||
<DataLoadingIndicator label="客服连线中" />
|
||||
</div>
|
||||
) : null}
|
||||
<iframe
|
||||
title="customer-service-chat"
|
||||
src={SUPPORT_CHAT_URL}
|
||||
onLoad={handleLoaded}
|
||||
className="h-full w-full bg-[linear-gradient(180deg,#061923,#020A11)]"
|
||||
allow="microphone; camera; clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopSupportModal
|
||||
@@ -295,8 +295,8 @@ export default {
|
||||
},
|
||||
fields: {
|
||||
username: {
|
||||
label: 'Account / Phone:',
|
||||
placeholder: 'Enter account or mobile number',
|
||||
label: 'Mobile:',
|
||||
placeholder: 'Enter mobile number',
|
||||
},
|
||||
password: {
|
||||
label: 'Password:',
|
||||
@@ -317,9 +317,13 @@ export default {
|
||||
submit: 'Register',
|
||||
},
|
||||
fields: {
|
||||
username: {
|
||||
label: 'Account / Phone:',
|
||||
placeholder: 'Enter account or mobile number',
|
||||
mobile: {
|
||||
label: 'Mobile:',
|
||||
placeholder: 'Enter mobile number',
|
||||
},
|
||||
captcha: {
|
||||
label: 'Code:',
|
||||
placeholder: 'Enter verification code',
|
||||
},
|
||||
password: {
|
||||
label: 'Password:',
|
||||
@@ -342,8 +346,20 @@ export default {
|
||||
submitFailed: 'Registration failed. Please try again later.',
|
||||
unauthorized: 'Registration is not authorized. Please try again later.',
|
||||
},
|
||||
sms: {
|
||||
countdown: '{{seconds}}s',
|
||||
errors: {
|
||||
submitFailed: 'Failed to send code. Please try again later.',
|
||||
},
|
||||
send: 'Get code',
|
||||
sending: 'Sending...',
|
||||
success: 'Verification code sent.',
|
||||
},
|
||||
},
|
||||
validation: {
|
||||
captcha: {
|
||||
required: 'Please enter the verification code.',
|
||||
},
|
||||
username: {
|
||||
required: 'Please enter your mobile number.',
|
||||
invalidPhone: 'Please enter a valid mobile number.',
|
||||
|
||||
@@ -295,8 +295,8 @@ export default {
|
||||
},
|
||||
fields: {
|
||||
username: {
|
||||
label: 'Akun / Telepon:',
|
||||
placeholder: 'Masukkan akun atau nomor ponsel',
|
||||
label: 'Nomor Ponsel:',
|
||||
placeholder: 'Masukkan nomor ponsel',
|
||||
},
|
||||
password: {
|
||||
label: 'Kata Sandi:',
|
||||
@@ -317,9 +317,13 @@ export default {
|
||||
submit: 'Daftar',
|
||||
},
|
||||
fields: {
|
||||
username: {
|
||||
label: 'Akun / Telepon:',
|
||||
placeholder: 'Masukkan akun atau nomor ponsel',
|
||||
mobile: {
|
||||
label: 'Nomor Ponsel:',
|
||||
placeholder: 'Masukkan nomor ponsel',
|
||||
},
|
||||
captcha: {
|
||||
label: 'Kode:',
|
||||
placeholder: 'Masukkan kode verifikasi',
|
||||
},
|
||||
password: {
|
||||
label: 'Kata Sandi:',
|
||||
@@ -342,8 +346,20 @@ export default {
|
||||
submitFailed: 'Pendaftaran gagal. Silakan coba lagi nanti.',
|
||||
unauthorized: 'Pendaftaran tidak diizinkan. Silakan coba lagi nanti.',
|
||||
},
|
||||
sms: {
|
||||
countdown: '{{seconds}}d',
|
||||
errors: {
|
||||
submitFailed: 'Gagal mengirim kode. Silakan coba lagi nanti.',
|
||||
},
|
||||
send: 'Ambil kode',
|
||||
sending: 'Mengirim...',
|
||||
success: 'Kode verifikasi telah dikirim.',
|
||||
},
|
||||
},
|
||||
validation: {
|
||||
captcha: {
|
||||
required: 'Silakan masukkan kode verifikasi.',
|
||||
},
|
||||
username: {
|
||||
required: 'Silakan masukkan nomor ponsel.',
|
||||
invalidPhone: 'Silakan masukkan nomor ponsel yang valid.',
|
||||
|
||||
@@ -300,8 +300,8 @@ export default {
|
||||
},
|
||||
fields: {
|
||||
username: {
|
||||
label: 'Akaun / Telefon:',
|
||||
placeholder: 'Masukkan akaun atau nombor telefon',
|
||||
label: 'Nombor Telefon:',
|
||||
placeholder: 'Masukkan nombor telefon',
|
||||
},
|
||||
password: {
|
||||
label: 'Kata Laluan:',
|
||||
@@ -322,9 +322,13 @@ export default {
|
||||
submit: 'Daftar',
|
||||
},
|
||||
fields: {
|
||||
username: {
|
||||
label: 'Akaun / Telefon:',
|
||||
placeholder: 'Masukkan akaun atau nombor telefon',
|
||||
mobile: {
|
||||
label: 'Nombor Telefon:',
|
||||
placeholder: 'Masukkan nombor telefon',
|
||||
},
|
||||
captcha: {
|
||||
label: 'Kod:',
|
||||
placeholder: 'Masukkan kod pengesahan',
|
||||
},
|
||||
password: {
|
||||
label: 'Kata Laluan:',
|
||||
@@ -347,8 +351,20 @@ export default {
|
||||
submitFailed: 'Pendaftaran gagal. Sila cuba lagi kemudian.',
|
||||
unauthorized: 'Pendaftaran tidak dibenarkan. Sila cuba lagi kemudian.',
|
||||
},
|
||||
sms: {
|
||||
countdown: '{{seconds}}s',
|
||||
errors: {
|
||||
submitFailed: 'Gagal menghantar kod. Sila cuba lagi kemudian.',
|
||||
},
|
||||
send: 'Dapatkan kod',
|
||||
sending: 'Menghantar...',
|
||||
success: 'Kod pengesahan telah dihantar.',
|
||||
},
|
||||
},
|
||||
validation: {
|
||||
captcha: {
|
||||
required: 'Sila masukkan kod pengesahan.',
|
||||
},
|
||||
username: {
|
||||
required: 'Sila masukkan nombor telefon anda.',
|
||||
invalidPhone: 'Sila masukkan nombor telefon yang sah.',
|
||||
|
||||
@@ -283,8 +283,8 @@ export default {
|
||||
},
|
||||
fields: {
|
||||
username: {
|
||||
label: '账号/电话:',
|
||||
placeholder: '请输入账号或手机号',
|
||||
label: '手机号:',
|
||||
placeholder: '请输入手机号',
|
||||
},
|
||||
password: {
|
||||
label: '密码:',
|
||||
@@ -305,9 +305,13 @@ export default {
|
||||
submit: '注册',
|
||||
},
|
||||
fields: {
|
||||
username: {
|
||||
label: '账号/电话:',
|
||||
placeholder: '请输入账号或手机号',
|
||||
mobile: {
|
||||
label: '手机号:',
|
||||
placeholder: '请输入手机号',
|
||||
},
|
||||
captcha: {
|
||||
label: '验证码:',
|
||||
placeholder: '请输入验证码',
|
||||
},
|
||||
password: {
|
||||
label: '密码:',
|
||||
@@ -330,8 +334,20 @@ export default {
|
||||
submitFailed: '注册失败,请稍后重试',
|
||||
unauthorized: '注册未授权,请稍后重试',
|
||||
},
|
||||
sms: {
|
||||
countdown: '{{seconds}}秒',
|
||||
errors: {
|
||||
submitFailed: '验证码发送失败,请稍后重试',
|
||||
},
|
||||
send: '获取验证码',
|
||||
sending: '发送中...',
|
||||
success: '验证码已发送',
|
||||
},
|
||||
},
|
||||
validation: {
|
||||
captcha: {
|
||||
required: '请输入验证码',
|
||||
},
|
||||
username: {
|
||||
required: '请输入手机号',
|
||||
invalidPhone: '请输入正确的手机号',
|
||||
|
||||
Reference in New Issue
Block a user