Files
Genarrative/src/components/auth/LoginScreen.tsx
kdletters 59facaf14b 继续收口认证入口弹窗壳层
新增 PlatformAuthModalShell 统一认证白底弹窗壳层

登录入口和邀请码弹窗复用共享认证壳层

补充认证壳层和 AuthGate 接入测试

同步 PlatformUiKit 文档和 Hermes 决策记录
2026-06-11 18:09:54 +08:00

706 lines
21 KiB
TypeScript

import { Check } from 'lucide-react';
import { type ReactNode, 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 { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
type LegalDocumentId,
persistLegalConsent,
readStoredLegalConsent,
} from '../common/legalDocuments';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { CaptchaChallengeField } from './CaptchaChallengeField';
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password';
const LOGIN_TAB_ITEMS: Array<{ id: LoginTab; label: string }> = [
{ id: 'phone', label: '短信登录' },
{ id: 'password', label: '密码登录' },
];
type LoginScreenProps = {
isOpen: boolean;
platformTheme: PlatformTheme;
availableLoginMethods: AuthLoginMethod[];
sendingCode: boolean;
loggingIn: boolean;
wechatLoading: boolean;
error: string;
captchaChallenge: AuthCaptchaChallenge | null;
onClose: () => void;
onSendCode: (
phone: string,
scene: SmsScene,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onPhoneSubmit: (phone: string, code: 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,
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 [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
const [legalConsentChecked, setLegalConsentChecked] = useState(false);
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | null>(null);
const passwordLoginEnabled = true;
const phoneLoginEnabled = true;
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
const miniProgramRuntime = isWechatMiniProgramWebViewRuntime();
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
useEffect(() => {
if (!isOpen) {
return;
}
// 每次重新打开弹窗都丢弃上一次未完成的表单草稿,只保留最近成功登录手机号回填。
setIsResetPanelOpen(false);
setPhone(getStoredLastLoginPhone());
setPassword('');
setCode('');
setResetPhone('');
setResetCode('');
setResetPasswordValue('');
setCaptchaAnswer('');
setCooldownSeconds(0);
setResetCooldownSeconds(0);
setHint('');
setLegalConsentChecked(readStoredLegalConsent());
setActiveLegalDocumentId(null);
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
}, [isOpen, phoneLoginEnabled]);
useEffect(() => {
if (
activeLoginTab === 'phone' &&
!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;
const activeLegalDocument = activeLegalDocumentId
? getLegalDocument(activeLegalDocumentId)
: null;
const toggleLegalConsent = () => {
setLegalConsentChecked((current) => {
const nextChecked = !current;
if (nextChecked) {
persistLegalConsent();
}
return nextChecked;
});
};
const legalConsentRow = (
<LegalConsentRow
checked={legalConsentChecked}
onToggle={toggleLegalConsent}
onOpenDocument={setActiveLegalDocumentId}
/>
);
return (
<>
<PlatformAuthModalShell
title={isResetPanelOpen ? '重置密码' : '账号入口'}
platformTheme={platformTheme}
onClose={onClose}
closeLabel="关闭登录弹窗"
panelClassName="!max-w-md"
>
{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 ? (
<PlatformSegmentedTabs
items={
passwordLoginEnabled
? LOGIN_TAB_ITEMS
: LOGIN_TAB_ITEMS.slice(0, 1)
}
activeId={activeLoginTab}
onChange={setActiveLoginTab}
columns={passwordLoginEnabled ? 'two' : 'one'}
frame="bare"
surface="transparent"
tone="underline"
size="tab"
semantics="tabs"
ariaLabel="登录方式"
/>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
) {
return;
}
void onPasswordSubmit(phone, password);
}}
>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
{error ? <ErrorBanner message={error} /> : null}
{legalConsentRow}
<div className="flex flex-col gap-2">
<PlatformActionButton
type="submit"
disabled={
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
}
size="lg"
>
{loggingIn ? '登录中' : '登录'}
</PlatformActionButton>
<button
type="button"
className="self-end text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
</div>
{wechatLoginEnabled && !miniProgramRuntime ? (
<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}
legalConsentChecked={legalConsentChecked}
legalConsentNode={legalConsentRow}
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}
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled &&
!miniProgramRuntime ? (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="px-4 py-4"
>
</PlatformEmptyState>
) : null}
</div>
)}
</PlatformAuthModalShell>
<LegalDocumentModal
document={activeLegalDocument}
open={Boolean(activeLegalDocument)}
platformTheme={platformTheme}
onClose={() => setActiveLegalDocumentId(null)}
/>
</>
);
}
function LegalConsentRow({
checked,
onToggle,
onOpenDocument,
}: {
checked: boolean;
onToggle: () => void;
onOpenDocument: (documentId: LegalDocumentId) => void;
}) {
const openDocument = (documentId: LegalDocumentId) => {
onOpenDocument(documentId);
};
return (
<div className="flex items-start gap-2.5 text-xs leading-5 text-[var(--platform-text-base)]">
<button
type="button"
role="switch"
aria-checked={checked}
aria-label="同意法律协议"
onClick={onToggle}
className={`mt-0.5 flex h-5 w-9 shrink-0 items-center rounded-full border p-0.5 transition ${
checked
? 'justify-end border-[var(--platform-button-primary-border)] [background:var(--platform-profile-action-fill)]'
: 'justify-start border-[var(--platform-subpanel-border)] [background:var(--platform-button-secondary-fill)]'
}`}
>
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--platform-button-primary-text)] text-[var(--platform-cool-text)] shadow-sm">
{checked ? <Check className="h-3 w-3" /> : null}
</span>
</button>
<div>
<LegalLink
label="《用户协议》"
onClick={() => openDocument('user-agreement')}
/>
<LegalLink
label="《隐私政策》"
onClick={() => openDocument('privacy-policy')}
/>
<LegalLink
label="《免责声明》"
onClick={() => openDocument('disclaimer')}
/>
</div>
</div>
);
}
function LegalLink({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
className="mx-0.5 align-baseline font-semibold text-[var(--platform-cool-text)] underline-offset-2 hover:underline"
onClick={onClick}
>
{label}
</button>
);
}
function PhoneCodeForm({
phone,
code,
captchaAnswer,
captchaChallenge,
cooldownSeconds,
sendingCode,
loggingIn,
error,
hint,
submitLabel,
enabled,
legalConsentChecked,
legalConsentNode,
showPhoneField,
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;
legalConsentChecked: boolean;
legalConsentNode: ReactNode;
showPhoneField: boolean;
onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void;
onCaptchaAnswerChange: (value: string) => void;
onSendCode: () => Promise<void>;
onSubmit: () => Promise<void>;
}) {
if (!enabled) {
return null;
}
const submitBlocked =
loggingIn || !phone.trim() || !code.trim() || !legalConsentChecked;
return (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (submitBlocked) {
return;
}
void onSubmit();
}}
>
{showPhoneField ? (
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => onPhoneChange(event.target.value)}
placeholder="13800000000"
/>
</label>
) : null}
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<div className="flex gap-3">
<PlatformTextField
className="min-w-0 flex-1"
inputMode="numeric"
value={code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder="输入验证码"
/>
<PlatformActionButton
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()}
>
{sendingCode
? '发送中'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</PlatformActionButton>
</div>
</label>
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={onCaptchaAnswerChange}
/>
{hint ? <SuccessBanner message={hint} /> : null}
{error ? <ErrorBanner message={error} /> : null}
{legalConsentNode}
<PlatformActionButton type="submit" disabled={submitBlocked} size="lg">
{loggingIn ? '处理中' : submitLabel}
</PlatformActionButton>
</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">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => onPhoneChange(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<div className="flex gap-3">
<PlatformTextField
className="min-w-0 flex-1"
inputMode="numeric"
value={code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder="输入验证码"
/>
<PlatformActionButton
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()}
>
{sendingCode
? '发送中'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</PlatformActionButton>
</div>
</label>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
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">
<PlatformActionButton tone="secondary" size="lg" onClick={onBack}>
</PlatformActionButton>
<PlatformActionButton
type="submit"
disabled={
loggingIn || !phone.trim() || !code.trim() || !password.trim()
}
size="lg"
>
{loggingIn ? '处理中' : '重置密码'}
</PlatformActionButton>
</div>
</form>
);
}
function WechatButton({
loading,
disabled,
onClick,
}: {
loading: boolean;
disabled: boolean;
onClick: () => Promise<void>;
}) {
return (
<PlatformActionButton
disabled={loading || disabled}
tone="secondary"
size="lg"
onClick={() => void onClick()}
>
{loading ? '跳转中' : '微信登录'}
</PlatformActionButton>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<PlatformStatusMessage tone="error" surface="profile">
{message}
</PlatformStatusMessage>
);
}
function SuccessBanner({ message }: { message: string }) {
return (
<PlatformStatusMessage tone="success" surface="profile">
{message}
</PlatformStatusMessage>
);
}