迁移后端认证与拆分 Spacetime 客户端

This commit is contained in:
2026-04-24 14:10:11 +08:00
parent ef53028be5
commit 4f369617c7
55 changed files with 9206 additions and 343 deletions

View File

@@ -66,6 +66,7 @@ function renderAccountModal(overrides?: {
expiresInSeconds: 300,
})}
onChangePhone={vi.fn().mockResolvedValue(undefined)}
onChangePassword={vi.fn().mockResolvedValue(undefined)}
/>,
);
}

View File

@@ -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>

View File

@@ -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('');

View File

@@ -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>;
}