Files
Genarrative/src/components/auth/LoginScreen.tsx

691 lines
21 KiB
TypeScript

import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type {
AuthCaptchaChallenge,
AuthLoginMethod,
} from '../../services/authService';
import { getStoredLastLoginPhone } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password' | 'register';
type LoginScreenProps = {
isOpen: boolean;
platformTheme: PlatformTheme;
availableLoginMethods: AuthLoginMethod[];
sendingCode: boolean;
loggingIn: boolean;
wechatLoading: boolean;
error: string;
captchaChallenge: AuthCaptchaChallenge | null;
initialMode?: 'login' | 'register';
initialInviteCode?: string;
onClose: () => void;
onSendCode: (
phone: string,
scene: SmsScene,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onPhoneSubmit: (
phone: string,
code: string,
inviteCode?: string,
) => Promise<void>;
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
onResetPassword: (
phone: string,
code: string,
newPassword: string,
) => Promise<void>;
onStartWechatLogin: () => Promise<void>;
};
export function LoginScreen({
isOpen,
platformTheme,
availableLoginMethods,
sendingCode,
loggingIn,
wechatLoading,
error,
captchaChallenge,
initialMode = 'login',
initialInviteCode = '',
onClose,
onSendCode,
onPhoneSubmit,
onPasswordSubmit,
onResetPassword,
onStartWechatLogin,
}: LoginScreenProps) {
const [isResetPanelOpen, setIsResetPanelOpen] = useState(false);
const [phone, setPhone] = useState(() => getStoredLastLoginPhone());
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [resetPhone, setResetPhone] = useState('');
const [resetCode, setResetCode] = useState('');
const [resetPasswordValue, setResetPasswordValue] = useState('');
const [inviteCode, setInviteCode] = useState(initialInviteCode);
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
const passwordLoginEnabled = availableLoginMethods.includes('password');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
useEffect(() => {
if (!isOpen) {
return;
}
// 每次重新打开弹窗都丢弃上一次未完成的表单草稿,只保留最近成功登录手机号回填。
setIsResetPanelOpen(false);
setPhone(getStoredLastLoginPhone());
setPassword('');
setCode('');
setResetPhone('');
setResetCode('');
setResetPasswordValue('');
setInviteCode(initialInviteCode);
setCaptchaAnswer('');
setCooldownSeconds(0);
setResetCooldownSeconds(0);
setHint('');
setActiveLoginTab(
initialMode === 'register' && phoneLoginEnabled
? 'register'
: phoneLoginEnabled
? 'phone'
: 'password',
);
}, [initialInviteCode, initialMode, isOpen, phoneLoginEnabled]);
useEffect(() => {
if (
(activeLoginTab === 'phone' || activeLoginTab === 'register') &&
!phoneLoginEnabled &&
passwordLoginEnabled
) {
setActiveLoginTab('password');
return;
}
if (
activeLoginTab === 'password' &&
!passwordLoginEnabled &&
phoneLoginEnabled
) {
setActiveLoginTab('phone');
}
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearTimeout(timeoutId);
}, [cooldownSeconds]);
useEffect(() => {
if (resetCooldownSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setResetCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearTimeout(timeoutId);
}, [resetCooldownSeconds]);
if (!isOpen) {
return null;
}
const submitDisabled = loggingIn || sendingCode;
return (
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="auth-login-dialog-title"
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div
id="auth-login-dialog-title"
className="text-lg font-semibold text-[var(--platform-text-strong)]"
>
{isResetPanelOpen ? '重置密码' : '账号入口'}
</div>
<button
type="button"
onClick={onClose}
className="platform-icon-button p-2"
aria-label="关闭登录弹窗"
>
<X className="h-4 w-4" />
</button>
</div>
{isResetPanelOpen ? (
<PasswordResetPanel
phone={resetPhone}
code={resetCode}
password={resetPasswordValue}
sendingCode={sendingCode}
loggingIn={loggingIn}
cooldownSeconds={resetCooldownSeconds}
error={error}
onPhoneChange={setResetPhone}
onCodeChange={setResetCode}
onPasswordChange={setResetPasswordValue}
onBack={() => setIsResetPanelOpen(false)}
onSendCode={async () => {
const result = await onSendCode(resetPhone, 'reset_password');
setResetCooldownSeconds(result.cooldownSeconds);
}}
onSubmit={() =>
onResetPassword(resetPhone, resetCode, resetPasswordValue)
}
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled ? (
<div
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-3' : 'grid-cols-2'
}`}
role="tablist"
aria-label="登录方式"
>
<LoginTabButton
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
>
</LoginTabButton>
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
) : null}
<LoginTabButton
active={activeLoginTab === 'register'}
onClick={() => setActiveLoginTab('register')}
>
</LoginTabButton>
</div>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void onPasswordSubmit(phone, password);
}}
>
<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>
<input
className="platform-input"
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
{error ? <ErrorBanner message={error} /> : null}
<div className="flex flex-col gap-2">
<button
type="submit"
disabled={
submitDisabled || !phone.trim() || !password.trim()
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '登录中' : '登录'}
</button>
<button
type="button"
className="self-end text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
</div>
{wechatLoginEnabled ? (
<WechatButton
loading={wechatLoading}
disabled={submitDisabled}
onClick={onStartWechatLogin}
/>
) : null}
</form>
) : null}
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
<PhoneCodeForm
phone={phone}
code={code}
captchaAnswer={captchaAnswer}
captchaChallenge={captchaChallenge}
cooldownSeconds={cooldownSeconds}
sendingCode={sendingCode}
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="登录"
enabled={phoneLoginEnabled}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
onSendCode={async () => {
setHint('');
const result = await onSendCode(phone, 'login', {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
}}
onSubmit={() => onPhoneSubmit(phone, code)}
/>
) : null}
{phoneLoginEnabled && activeLoginTab === 'register' ? (
<PhoneCodeForm
phone={phone}
code={code}
inviteCode={inviteCode}
captchaAnswer={captchaAnswer}
captchaChallenge={captchaChallenge}
cooldownSeconds={cooldownSeconds}
sendingCode={sendingCode}
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="注册"
enabled={phoneLoginEnabled}
showPhoneField
showInviteCodeField
onPhoneChange={setPhone}
onCodeChange={setCode}
onInviteCodeChange={setInviteCode}
onCaptchaAnswerChange={setCaptchaAnswer}
onSendCode={async () => {
setHint('');
const result = await onSendCode(phone, 'login', {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
}}
onSubmit={() => onPhoneSubmit(phone, code, inviteCode)}
/>
) : null}
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
</div>
) : null}
</div>
)}
</div>
</div>
);
}
function LoginTabButton({
active,
children,
onClick,
}: {
active: boolean;
children: string;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
active
? 'text-[var(--platform-text-strong)]'
: 'text-[var(--platform-text-muted)]'
}`}
onClick={onClick}
>
<span>{children}</span>
{active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</button>
);
}
function PhoneCodeForm({
phone,
code,
inviteCode = '',
captchaAnswer,
captchaChallenge,
cooldownSeconds,
sendingCode,
loggingIn,
error,
hint,
submitLabel,
enabled,
showPhoneField,
showInviteCodeField = false,
onPhoneChange,
onCodeChange,
onInviteCodeChange,
onCaptchaAnswerChange,
onSendCode,
onSubmit,
}: {
phone: string;
code: string;
inviteCode?: string;
captchaAnswer: string;
captchaChallenge: AuthCaptchaChallenge | null;
cooldownSeconds: number;
sendingCode: boolean;
loggingIn: boolean;
error: string;
hint: string;
submitLabel: string;
enabled: boolean;
showPhoneField: boolean;
showInviteCodeField?: boolean;
onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void;
onInviteCodeChange?: (value: string) => void;
onCaptchaAnswerChange: (value: string) => void;
onSendCode: () => Promise<void>;
onSubmit: () => Promise<void>;
}) {
if (!enabled) {
return null;
}
return (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void onSubmit();
}}
>
{showPhoneField ? (
<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) => onPhoneChange(event.target.value)}
placeholder="13800000000"
/>
</label>
) : null}
{showInviteCodeField ? (
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="off"
value={inviteCode}
onChange={(event) => onInviteCodeChange?.(event.target.value)}
placeholder="邀请码"
/>
</label>
) : null}
<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) => onCodeChange(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 onSendCode()}
>
{sendingCode
? '发送中'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={onCaptchaAnswerChange}
/>
{hint ? <SuccessBanner message={hint} /> : null}
{error ? <ErrorBanner message={error} /> : null}
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim()}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '处理中' : submitLabel}
</button>
</form>
);
}
function PasswordResetPanel({
phone,
code,
password,
sendingCode,
loggingIn,
cooldownSeconds,
error,
onPhoneChange,
onCodeChange,
onPasswordChange,
onBack,
onSendCode,
onSubmit,
}: {
phone: string;
code: string;
password: string;
sendingCode: boolean;
loggingIn: boolean;
cooldownSeconds: number;
error: string;
onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onBack: () => void;
onSendCode: () => Promise<void>;
onSubmit: () => Promise<void>;
}) {
return (
<form
className="flex flex-col gap-4 px-5 py-5"
onSubmit={(event) => {
event.preventDefault();
void onSubmit();
}}
>
<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) => onPhoneChange(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) => onCodeChange(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 onSendCode()}
>
{sendingCode
? '发送中'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="new-password"
type="password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
placeholder="设置新密码"
/>
</label>
{error ? <ErrorBanner message={error} /> : null}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="platform-button platform-button--secondary h-12 px-4 text-base"
onClick={onBack}
>
</button>
<button
type="submit"
disabled={
loggingIn || !phone.trim() || !code.trim() || !password.trim()
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '处理中' : '重置密码'}
</button>
</div>
</form>
);
}
function WechatButton({
loading,
disabled,
onClick,
}: {
loading: boolean;
disabled: boolean;
onClick: () => Promise<void>;
}) {
return (
<button
type="button"
disabled={loading || disabled}
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void onClick()}
>
{loading ? '跳转中' : '微信登录'}
</button>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="platform-banner platform-banner--danger text-sm">
{message}
</div>
);
}
function SuccessBanner({ message }: { message: string }) {
return (
<div className="platform-banner platform-banner--success text-sm">
{message}
</div>
);
}