迁移后端认证与拆分 Spacetime 客户端
This commit is contained in:
@@ -66,6 +66,7 @@ function renderAccountModal(overrides?: {
|
||||
expiresInSeconds: 300,
|
||||
})}
|
||||
onChangePhone={vi.fn().mockResolvedValue(undefined)}
|
||||
onChangePassword={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ type AccountModalProps = {
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onChangePhone: (phone: string, code: string) => Promise<void>;
|
||||
onChangePassword: (
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
const SETTINGS_SECTIONS: Array<{
|
||||
@@ -285,24 +289,31 @@ export function AccountModal({
|
||||
changePhoneCaptchaChallenge,
|
||||
onSendChangePhoneCode,
|
||||
onChangePhone,
|
||||
onChangePassword,
|
||||
}: AccountModalProps) {
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<PrimarySettingsSection | null>(
|
||||
normalizeSettingsSection(initialSection),
|
||||
);
|
||||
const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false);
|
||||
const [isPasswordPanelOpen, setIsPasswordPanelOpen] = useState(false);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [changePhoneError, setChangePhoneError] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [changePhoneHint, setChangePhoneHint] = useState('');
|
||||
const [accountNotice, setAccountNotice] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [changingPhone, setChangingPhone] = useState(false);
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const settingsHomeRef = useRef<HTMLDivElement | null>(null);
|
||||
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const passwordTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
|
||||
if (!element) {
|
||||
@@ -325,6 +336,12 @@ export function AccountModal({
|
||||
setCooldownSeconds(0);
|
||||
}, []);
|
||||
|
||||
const resetPasswordDraft = useCallback(() => {
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setPasswordError('');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
@@ -332,11 +349,14 @@ export function AccountModal({
|
||||
|
||||
setActiveSection(normalizeSettingsSection(initialSection));
|
||||
setIsChangePhonePanelOpen(false);
|
||||
setIsPasswordPanelOpen(false);
|
||||
setAccountNotice('');
|
||||
sectionTriggerRef.current = null;
|
||||
changePhoneTriggerRef.current = null;
|
||||
passwordTriggerRef.current = null;
|
||||
resetChangePhoneDraft();
|
||||
}, [initialSection, isOpen, resetChangePhoneDraft]);
|
||||
resetPasswordDraft();
|
||||
}, [initialSection, isOpen, resetChangePhoneDraft, resetPasswordDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const settingsHome = settingsHomeRef.current;
|
||||
@@ -368,10 +388,12 @@ export function AccountModal({
|
||||
const closeSectionPanel = useCallback(() => {
|
||||
const sectionTrigger = sectionTriggerRef.current;
|
||||
setIsChangePhonePanelOpen(false);
|
||||
setIsPasswordPanelOpen(false);
|
||||
setActiveSection(null);
|
||||
resetChangePhoneDraft();
|
||||
resetPasswordDraft();
|
||||
focusAfterNextPaint(sectionTrigger);
|
||||
}, [focusAfterNextPaint, resetChangePhoneDraft]);
|
||||
}, [focusAfterNextPaint, resetChangePhoneDraft, resetPasswordDraft]);
|
||||
|
||||
const closeChangePhonePanel = useCallback(() => {
|
||||
const changePhoneTrigger = changePhoneTriggerRef.current;
|
||||
@@ -380,6 +402,13 @@ export function AccountModal({
|
||||
focusAfterNextPaint(changePhoneTrigger);
|
||||
}, [focusAfterNextPaint, resetChangePhoneDraft]);
|
||||
|
||||
const closePasswordPanel = useCallback(() => {
|
||||
const passwordTrigger = passwordTriggerRef.current;
|
||||
setIsPasswordPanelOpen(false);
|
||||
resetPasswordDraft();
|
||||
focusAfterNextPaint(passwordTrigger);
|
||||
}, [focusAfterNextPaint, resetPasswordDraft]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -556,6 +585,31 @@ export function AccountModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
登录密码
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
在独立面板中设置或修改账号密码。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
onClick={(event) => {
|
||||
passwordTriggerRef.current = event.currentTarget;
|
||||
setAccountNotice('');
|
||||
resetPasswordDraft();
|
||||
setIsPasswordPanelOpen(true);
|
||||
}}
|
||||
>
|
||||
修改密码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -893,6 +947,74 @@ export function AccountModal({
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
) : null}
|
||||
|
||||
{isPasswordPanelOpen ? (
|
||||
<OverlayPanel
|
||||
eyebrow="账号安全"
|
||||
title="修改登录密码"
|
||||
description="输入当前密码与新密码。首次设置密码时当前密码可留空。"
|
||||
onBack={closePasswordPanel}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>当前密码</span>
|
||||
<input
|
||||
className="platform-input h-11"
|
||||
value={currentPassword}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="首次设置可留空"
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>新密码</span>
|
||||
<input
|
||||
className="platform-input h-11"
|
||||
value={newPassword}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="设置新密码"
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{passwordError ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{passwordError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={changingPassword || !newPassword.trim()}
|
||||
className="platform-button platform-button--primary h-11 w-full text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setChangingPassword(true);
|
||||
setPasswordError('');
|
||||
try {
|
||||
await onChangePassword(currentPassword, newPassword);
|
||||
setAccountNotice('密码已更新。');
|
||||
closePasswordPanel();
|
||||
} catch (error) {
|
||||
setPasswordError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '修改密码失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{changingPassword ? '提交中...' : '确认修改密码'}
|
||||
</button>
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
) : null}
|
||||
</OverlayPanel>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
authEntry,
|
||||
bindWechatPhone,
|
||||
changePassword,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
resetPassword,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
@@ -648,6 +651,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
onChangePassword={async (currentPassword, newPassword) => {
|
||||
const nextUser = await changePassword(currentPassword, newPassword);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<LoginScreen
|
||||
@@ -660,11 +667,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
error={error}
|
||||
captchaChallenge={loginCaptchaChallenge}
|
||||
onClose={closeLoginModal}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
onSendCode={async (phone, scene, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'login', captcha);
|
||||
const result = await sendPhoneLoginCode(phone, scene, captcha);
|
||||
setLoginCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
@@ -682,7 +689,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
onPhoneSubmit={async (phone, code) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -699,6 +706,38 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onPasswordSubmit={async (username, password) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await authEntry(username, password);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
loginError instanceof Error
|
||||
? loginError.message
|
||||
: '登录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onResetPassword={async (phone, code, newPassword) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await resetPassword(phone, code, newPassword);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (resetError) {
|
||||
setError(
|
||||
resetError instanceof Error
|
||||
? resetError.message
|
||||
: '重置密码失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onStartWechatLogin={async () => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
} from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type SmsScene = 'login' | 'reset_password';
|
||||
|
||||
type LoginScreenProps = {
|
||||
isOpen: boolean;
|
||||
platformTheme: PlatformTheme;
|
||||
@@ -20,6 +22,7 @@ type LoginScreenProps = {
|
||||
onClose: () => void;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
scene: SmsScene,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
@@ -28,7 +31,13 @@ type LoginScreenProps = {
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onPasswordSubmit: (username: string, password: string) => Promise<void>;
|
||||
onResetPassword: (
|
||||
phone: string,
|
||||
code: string,
|
||||
newPassword: string,
|
||||
) => Promise<void>;
|
||||
onStartWechatLogin: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -43,14 +52,25 @@ export function LoginScreen({
|
||||
captchaChallenge,
|
||||
onClose,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
onPhoneSubmit,
|
||||
onPasswordSubmit,
|
||||
onResetPassword,
|
||||
onStartWechatLogin,
|
||||
}: LoginScreenProps) {
|
||||
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
|
||||
const [isResetPanelOpen, setIsResetPanelOpen] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [resetPhone, setResetPhone] = useState('');
|
||||
const [resetCode, setResetCode] = useState('');
|
||||
const [resetPasswordValue, setResetPasswordValue] = useState('');
|
||||
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');
|
||||
|
||||
@@ -63,15 +83,27 @@ export function LoginScreen({
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
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`}
|
||||
@@ -82,16 +114,14 @@ export function LoginScreen({
|
||||
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();
|
||||
}}
|
||||
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"
|
||||
@@ -103,122 +133,408 @@ export function LoginScreen({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4 px-5 py-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!phoneLoginEnabled) {
|
||||
return;
|
||||
}
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
{phoneLoginEnabled ? (
|
||||
<>
|
||||
<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 () => {
|
||||
setHint('');
|
||||
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 {
|
||||
// Error state is handled by the parent.
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
{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-4 px-5 py-5">
|
||||
<div className="grid grid-cols-2 gap-2 rounded-full bg-[var(--platform-subpanel-bg)] p-1">
|
||||
<TabButton
|
||||
active={activeTab === 'login'}
|
||||
label="登录"
|
||||
onClick={() => setActiveTab('login')}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'register'}
|
||||
label="注册"
|
||||
onClick={() => setActiveTab('register')}
|
||||
/>
|
||||
|
||||
{hint ? (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled ? (
|
||||
<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 ? '登录中' : '登录'}
|
||||
</button>
|
||||
) : null}
|
||||
{activeTab === 'login' ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!passwordLoginEnabled) {
|
||||
return;
|
||||
}
|
||||
void onPasswordSubmit(username, password);
|
||||
}}
|
||||
>
|
||||
{passwordLoginEnabled ? (
|
||||
<>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>账号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={wechatLoading || sendingCode || loggingIn}
|
||||
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void onStartWechatLogin();
|
||||
}}
|
||||
>
|
||||
{wechatLoading ? '跳转中' : '微信登录'}
|
||||
</button>
|
||||
) : null}
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
|
||||
{!phoneLoginEnabled && !wechatLoginEnabled ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
{passwordLoginEnabled ? (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitDisabled || !username.trim() || !password.trim()}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '登录中' : '登录'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-center text-sm text-[var(--platform-accent)]"
|
||||
onClick={() => setIsResetPanelOpen(true)}
|
||||
>
|
||||
忘记密码
|
||||
</button>
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
<WechatButton
|
||||
loading={wechatLoading}
|
||||
disabled={submitDisabled}
|
||||
onClick={onStartWechatLogin}
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
) : (
|
||||
<PhoneCodeForm
|
||||
phone={phone}
|
||||
code={code}
|
||||
captchaAnswer={captchaAnswer}
|
||||
captchaChallenge={captchaChallenge}
|
||||
cooldownSeconds={cooldownSeconds}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
error={error}
|
||||
hint={hint}
|
||||
submitLabel="注册并登录"
|
||||
enabled={phoneLoginEnabled}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!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 TabButton({
|
||||
active,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`h-10 rounded-full text-sm font-medium transition ${
|
||||
active
|
||||
? 'bg-[var(--platform-panel-bg)] text-[var(--platform-text-strong)] shadow-sm'
|
||||
: 'text-[var(--platform-text-muted)]'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneCodeForm({
|
||||
phone,
|
||||
code,
|
||||
captchaAnswer,
|
||||
captchaChallenge,
|
||||
cooldownSeconds,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
error,
|
||||
hint,
|
||||
submitLabel,
|
||||
enabled,
|
||||
onPhoneChange,
|
||||
onCodeChange,
|
||||
onCaptchaAnswerChange,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
}: {
|
||||
phone: string;
|
||||
code: string;
|
||||
captchaAnswer: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
cooldownSeconds: number;
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
error: string;
|
||||
hint: string;
|
||||
submitLabel: string;
|
||||
enabled: boolean;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onCodeChange: (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();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user