183 lines
6.7 KiB
TypeScript
183 lines
6.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
|
||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||
|
||
type BindPhoneScreenProps = {
|
||
user: AuthUser;
|
||
platformTheme: PlatformTheme;
|
||
sendingCode: boolean;
|
||
binding: boolean;
|
||
error: string;
|
||
captchaChallenge: AuthCaptchaChallenge | null;
|
||
onSendCode: (
|
||
phone: string,
|
||
captcha?: {
|
||
challengeId?: string;
|
||
answer?: string;
|
||
},
|
||
) => Promise<{
|
||
cooldownSeconds: number;
|
||
expiresInSeconds: number;
|
||
}>;
|
||
onSubmit: (phone: string, code: string) => Promise<void>;
|
||
onLogout: () => Promise<void>;
|
||
};
|
||
|
||
export function BindPhoneScreen({
|
||
user,
|
||
platformTheme,
|
||
sendingCode,
|
||
binding,
|
||
error,
|
||
captchaChallenge,
|
||
onSendCode,
|
||
onSubmit,
|
||
onLogout,
|
||
}: BindPhoneScreenProps) {
|
||
const [phone, setPhone] = useState('');
|
||
const [code, setCode] = useState('');
|
||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||
const [hint, setHint] = useState('');
|
||
|
||
useEffect(() => {
|
||
if (cooldownSeconds <= 0) {
|
||
return;
|
||
}
|
||
|
||
const timeoutId = window.setTimeout(() => {
|
||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||
}, 1000);
|
||
|
||
return () => {
|
||
window.clearTimeout(timeoutId);
|
||
};
|
||
}, [cooldownSeconds]);
|
||
|
||
return (
|
||
<div className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}>
|
||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
|
||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||
<div className="selection-hero-brand selection-hero-brand--left">
|
||
<div className="selection-hero-brand__title">百梦</div>
|
||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||
</div>
|
||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
|
||
账号激活
|
||
</p>
|
||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-[var(--platform-text-strong)] md:text-4xl">
|
||
绑定手机号
|
||
</h1>
|
||
<p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
|
||
微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。
|
||
</p>
|
||
<div className="platform-subpanel mt-8 rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||
当前登录身份:{user.displayName}
|
||
</div>
|
||
</div>
|
||
|
||
<form
|
||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void onSubmit(phone, code);
|
||
}}
|
||
>
|
||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||
<span>手机号</span>
|
||
<input
|
||
className="platform-input"
|
||
autoComplete="tel"
|
||
inputMode="numeric"
|
||
value={phone}
|
||
onChange={(event) => setPhone(event.target.value)}
|
||
placeholder="13800000000"
|
||
/>
|
||
</label>
|
||
|
||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||
<span>验证码</span>
|
||
<div className="flex gap-3">
|
||
<input
|
||
className="platform-input min-w-0 flex-1"
|
||
inputMode="numeric"
|
||
value={code}
|
||
onChange={(event) => setCode(event.target.value)}
|
||
placeholder="输入验证码"
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||
onClick={() => {
|
||
void (async () => {
|
||
try {
|
||
const result = await onSendCode(phone, {
|
||
challengeId: captchaChallenge?.challengeId,
|
||
answer: captchaAnswer,
|
||
});
|
||
setCooldownSeconds(result.cooldownSeconds);
|
||
setHint(
|
||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||
);
|
||
setCaptchaAnswer('');
|
||
} catch {
|
||
setHint('');
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{sendingCode
|
||
? '发送中...'
|
||
: cooldownSeconds > 0
|
||
? `${cooldownSeconds}s`
|
||
: '获取验证码'}
|
||
</button>
|
||
</div>
|
||
</label>
|
||
|
||
{hint ? (
|
||
<div className="platform-banner platform-banner--success text-sm">
|
||
{hint}
|
||
</div>
|
||
) : null}
|
||
|
||
<CaptchaChallengeField
|
||
challenge={captchaChallenge}
|
||
answer={captchaAnswer}
|
||
onAnswerChange={setCaptchaAnswer}
|
||
/>
|
||
|
||
{error ? (
|
||
<div className="platform-banner platform-banner--danger text-sm">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={binding || !phone.trim() || !code.trim()}
|
||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
className="platform-button platform-button--ghost h-11 px-4 text-sm"
|
||
onClick={() => {
|
||
void onLogout();
|
||
}}
|
||
>
|
||
返回其他登录方式
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|