新增 PlatformAuthModalShell 统一认证白底弹窗壳层 登录入口和邀请码弹窗复用共享认证壳层 补充认证壳层和 AuthGate 接入测试 同步 PlatformUiKit 文档和 Hermes 决策记录
706 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|