1
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,20 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
getStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
consumeAuthCallbackResult: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -30,12 +35,25 @@ vi.mock('../../services/authService', () => ({
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
revokeAuthSession: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useGameSettings', () => ({
|
||||
useGameSettings: () => ({
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: () => {},
|
||||
hasHydratedSettings: true,
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./AccountModal', () => ({
|
||||
@@ -60,6 +78,12 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMocks.getStoredAccessToken.mockReturnValue(null);
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||
authMocks.ensureAutoAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
credentials: {
|
||||
@@ -69,7 +93,22 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate prefers login screen when phone login is available', async () => {
|
||||
function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => void }) {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
authUi?.requireAuth(onAuthenticated);
|
||||
}}
|
||||
>
|
||||
进入作品
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
@@ -80,7 +119,43 @@ test('auth gate prefers login screen when phone login is available', async () =>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('账号登录')).toBeTruthy();
|
||||
expect(screen.getByText('手机号')).toBeTruthy();
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '登录' })).toBeTruthy();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAuthenticated = vi.fn();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={onAuthenticated} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '登录账号' });
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
|
||||
'13800000000',
|
||||
'123456',
|
||||
);
|
||||
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useGameSettings } from '../../hooks/useGameSettings';
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
getStoredAccessToken,
|
||||
@@ -30,7 +38,10 @@ import {
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext } from './AuthUiContext';
|
||||
import {
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
} from './AuthUiContext';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
|
||||
@@ -61,7 +72,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [bindingPhone, setBindingPhone] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [showAccountModal, setShowAccountModal] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [initialSettingsSection, setInitialSettingsSection] =
|
||||
useState<PlatformSettingsSection>('appearance');
|
||||
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
@@ -75,6 +89,55 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
|
||||
const settings = useGameSettings(user?.id ?? null);
|
||||
const platformThemeClass = `platform-theme--${settings.platformTheme}`;
|
||||
|
||||
const readyUser = status === 'ready' ? user : null;
|
||||
|
||||
const closeLoginModal = useCallback(() => {
|
||||
pendingProtectedActionRef.current = null;
|
||||
setShowLoginModal(false);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const openLoginModal = useCallback(
|
||||
(postLoginAction?: (() => void) | null) => {
|
||||
if (readyUser) {
|
||||
postLoginAction?.();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingProtectedActionRef.current = postLoginAction ?? null;
|
||||
setShowLoginModal(true);
|
||||
},
|
||||
[readyUser],
|
||||
);
|
||||
|
||||
const requireAuth = useCallback(
|
||||
(action: () => void) => {
|
||||
openLoginModal(action);
|
||||
},
|
||||
[openLoginModal],
|
||||
);
|
||||
|
||||
const openSettingsModal = useCallback(
|
||||
(section: PlatformSettingsSection = 'appearance') => {
|
||||
if (readyUser) {
|
||||
setInitialSettingsSection(section);
|
||||
setShowSettingsModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
openLoginModal();
|
||||
},
|
||||
[openLoginModal, readyUser],
|
||||
);
|
||||
|
||||
const openAccountModal = useCallback(() => {
|
||||
openSettingsModal('account');
|
||||
}, [openSettingsModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
@@ -163,6 +226,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
if (callbackResult?.error && isActive) {
|
||||
setError(callbackResult.error);
|
||||
setShowLoginModal(true);
|
||||
}
|
||||
|
||||
const token = getStoredAccessToken();
|
||||
@@ -217,7 +281,20 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAccountModal || status !== 'ready') {
|
||||
if (!readyUser) {
|
||||
setShowSettingsModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowLoginModal(false);
|
||||
|
||||
const pendingAction = pendingProtectedActionRef.current;
|
||||
pendingProtectedActionRef.current = null;
|
||||
pendingAction?.();
|
||||
}, [readyUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSettingsModal || status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -299,24 +376,47 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [showAccountModal, status]);
|
||||
}, [showSettingsModal, status]);
|
||||
|
||||
const authUiValue = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
openAccountModal: () => setShowAccountModal(true),
|
||||
user: readyUser,
|
||||
openLoginModal,
|
||||
requireAuth,
|
||||
openSettingsModal,
|
||||
openAccountModal,
|
||||
logout: async () => {
|
||||
await logoutAuthUser();
|
||||
setShowAccountModal(false);
|
||||
setShowSettingsModal(false);
|
||||
},
|
||||
setGlobalAccountActionsVisible: setShowGlobalAccountActions,
|
||||
musicVolume: settings.musicVolume,
|
||||
setMusicVolume: settings.setMusicVolume,
|
||||
platformTheme: settings.platformTheme,
|
||||
setPlatformTheme: settings.setPlatformTheme,
|
||||
isHydratingSettings: settings.isHydratingSettings,
|
||||
isPersistingSettings: settings.isPersistingSettings,
|
||||
settingsError: settings.settingsError,
|
||||
}),
|
||||
[user],
|
||||
[
|
||||
openAccountModal,
|
||||
openLoginModal,
|
||||
openSettingsModal,
|
||||
readyUser,
|
||||
requireAuth,
|
||||
settings.isHydratingSettings,
|
||||
settings.isPersistingSettings,
|
||||
settings.musicVolume,
|
||||
settings.platformTheme,
|
||||
settings.setMusicVolume,
|
||||
settings.setPlatformTheme,
|
||||
settings.settingsError,
|
||||
],
|
||||
);
|
||||
|
||||
if (status === 'checking') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
|
||||
正在校验登录状态...
|
||||
</div>
|
||||
);
|
||||
@@ -324,84 +424,17 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
|
||||
if (status === 'recovering') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
|
||||
正在自动创建或恢复账号...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<LoginScreen
|
||||
availableLoginMethods={availableLoginMethods}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
wechatLoading={wechatLoading}
|
||||
error={error}
|
||||
captchaChallenge={loginCaptchaChallenge}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'login', captcha);
|
||||
setLoginCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setLoginCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
loginError instanceof Error
|
||||
? loginError.message
|
||||
: '登录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onStartWechatLogin={async () => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await startWechatLogin();
|
||||
} catch (wechatError) {
|
||||
setError(
|
||||
wechatError instanceof Error
|
||||
? wechatError.message
|
||||
: '微信登录暂不可用,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setWechatLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'pending_bind_phone' && user) {
|
||||
return (
|
||||
<BindPhoneScreen
|
||||
user={user}
|
||||
platformTheme={settings.platformTheme}
|
||||
sendingCode={sendingCode}
|
||||
binding={bindingPhone}
|
||||
error={error}
|
||||
@@ -455,17 +488,19 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'ready' || !user) {
|
||||
if (status !== 'ready' && status !== 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200">
|
||||
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
<div className="text-base font-medium text-zinc-50">登录状态异常</div>
|
||||
<div className="mt-3 text-sm leading-6 text-zinc-300">
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}>
|
||||
<div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">
|
||||
<div className="text-base font-medium text-[var(--platform-text-strong)]">
|
||||
登录状态异常
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{error || '账号恢复失败,请刷新页面后重试。'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 rounded-full border border-amber-300/30 px-4 py-2 text-sm text-amber-100 transition hover:border-amber-300/60 hover:bg-amber-300/10"
|
||||
className="platform-button platform-button--primary mt-5"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
@@ -480,140 +515,226 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return (
|
||||
<AuthUiContext.Provider value={authUiValue}>
|
||||
<div className="relative">
|
||||
{showGlobalAccountActions ? (
|
||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white"
|
||||
onClick={() => setShowAccountModal(true)}
|
||||
>
|
||||
{user.displayName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
|
||||
onClick={() => {
|
||||
void logoutAuthUser();
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
<div className={`platform-theme ${platformThemeClass}`}>
|
||||
{showGlobalAccountActions ? (
|
||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||
{readyUser ? (
|
||||
<div className="platform-auth-card pointer-events-auto flex items-center gap-2 rounded-full px-3 py-2 text-xs text-[var(--platform-text-base)]">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--secondary min-h-0 rounded-full px-2.5 py-1 text-[11px]"
|
||||
onClick={() => openAccountModal()}
|
||||
>
|
||||
{readyUser.displayName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-2.5 py-1 text-[11px]"
|
||||
onClick={() => {
|
||||
void logoutAuthUser();
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-auth-card pointer-events-auto rounded-full px-3 py-2 text-xs font-medium text-[var(--platform-text-strong)] transition hover:-translate-y-px"
|
||||
onClick={() => openLoginModal()}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<AccountModal
|
||||
user={user}
|
||||
isOpen={showAccountModal}
|
||||
riskBlocks={riskBlocks}
|
||||
sessions={sessions}
|
||||
auditLogs={auditLogs}
|
||||
loadingRiskBlocks={loadingRiskBlocks}
|
||||
loadingSessions={loadingSessions}
|
||||
loadingAuditLogs={loadingAuditLogs}
|
||||
onClose={() => setShowAccountModal(false)}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setShowAccountModal(false);
|
||||
}}
|
||||
onRefreshRiskBlocks={async () => {
|
||||
setLoadingRiskBlocks(true);
|
||||
try {
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
} catch (blockError) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
}}
|
||||
onLiftRiskBlock={async (scopeType) => {
|
||||
try {
|
||||
await liftAuthRiskBlock(scopeType);
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (liftError) {
|
||||
setError(
|
||||
liftError instanceof Error
|
||||
? liftError.message
|
||||
: '解除保护失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onRefreshSessions={async () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
setSessions(await getAuthSessions());
|
||||
} catch (sessionError) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}}
|
||||
onRefreshAuditLogs={async () => {
|
||||
setLoadingAuditLogs(true);
|
||||
try {
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (auditError) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLogoutAll={async () => {
|
||||
await logoutAllAuthSessions();
|
||||
setShowAccountModal(false);
|
||||
}}
|
||||
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
|
||||
onSendChangePhoneCode={async (phone, captcha) => {
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(
|
||||
phone,
|
||||
'change_phone',
|
||||
captcha,
|
||||
);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setChangePhoneCaptchaChallenge(captchaChallenge);
|
||||
) : null}
|
||||
{readyUser ? (
|
||||
<AccountModal
|
||||
user={readyUser}
|
||||
isOpen={showSettingsModal}
|
||||
initialSection={initialSettingsSection}
|
||||
platformTheme={settings.platformTheme}
|
||||
riskBlocks={riskBlocks}
|
||||
sessions={sessions}
|
||||
auditLogs={auditLogs}
|
||||
loadingRiskBlocks={loadingRiskBlocks}
|
||||
loadingSessions={loadingSessions}
|
||||
loadingAuditLogs={loadingAuditLogs}
|
||||
isHydratingSettings={settings.isHydratingSettings}
|
||||
isPersistingSettings={settings.isPersistingSettings}
|
||||
settingsError={settings.settingsError}
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
onPlatformThemeChange={settings.setPlatformTheme}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setShowSettingsModal(false);
|
||||
}}
|
||||
onRefreshRiskBlocks={async () => {
|
||||
setLoadingRiskBlocks(true);
|
||||
try {
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
} catch (blockError) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
}}
|
||||
onLiftRiskBlock={async (scopeType) => {
|
||||
try {
|
||||
await liftAuthRiskBlock(scopeType);
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (liftError) {
|
||||
setError(
|
||||
liftError instanceof Error
|
||||
? liftError.message
|
||||
: '解除保护失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onRefreshSessions={async () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
setSessions(await getAuthSessions());
|
||||
} catch (sessionError) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}}
|
||||
onRefreshAuditLogs={async () => {
|
||||
setLoadingAuditLogs(true);
|
||||
try {
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (auditError) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLogoutAll={async () => {
|
||||
await logoutAllAuthSessions();
|
||||
setShowSettingsModal(false);
|
||||
}}
|
||||
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
|
||||
onSendChangePhoneCode={async (phone, captcha) => {
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(
|
||||
phone,
|
||||
'change_phone',
|
||||
captcha,
|
||||
);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setChangePhoneCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
throw sendError;
|
||||
}
|
||||
}}
|
||||
onChangePhone={async (phone, code) => {
|
||||
const nextUser = await changePhoneNumber(phone, code);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<LoginScreen
|
||||
isOpen={showLoginModal}
|
||||
platformTheme={settings.platformTheme}
|
||||
availableLoginMethods={availableLoginMethods}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
wechatLoading={wechatLoading}
|
||||
error={error}
|
||||
captchaChallenge={loginCaptchaChallenge}
|
||||
onClose={closeLoginModal}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'login', captcha);
|
||||
setLoginCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setLoginCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
throw sendError;
|
||||
}
|
||||
}}
|
||||
onChangePhone={async (phone, code) => {
|
||||
const nextUser = await changePhoneNumber(phone, code);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
loginError instanceof Error
|
||||
? loginError.message
|
||||
: '登录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onStartWechatLogin={async () => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await startWechatLogin();
|
||||
} catch (wechatError) {
|
||||
setError(
|
||||
wechatError instanceof Error
|
||||
? wechatError.message
|
||||
: '微信登录暂不可用,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setWechatLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</AuthUiContext.Provider>
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
|
||||
export type PlatformSettingsSection =
|
||||
| 'appearance'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'devices'
|
||||
| 'logs';
|
||||
|
||||
type AuthUiContextValue = {
|
||||
user: AuthUser | null;
|
||||
openLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
logout: () => Promise<void>;
|
||||
setGlobalAccountActionsVisible: (visible: boolean) => void;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
platformTheme: PlatformTheme;
|
||||
setPlatformTheme: (theme: PlatformTheme) => void;
|
||||
isHydratingSettings: boolean;
|
||||
isPersistingSettings: boolean;
|
||||
settingsError: string | null;
|
||||
};
|
||||
|
||||
export const AuthUiContext = createContext<AuthUiContextValue | null>(null);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type BindPhoneScreenProps = {
|
||||
user: AuthUser;
|
||||
platformTheme: PlatformTheme;
|
||||
sendingCode: boolean;
|
||||
binding: boolean;
|
||||
error: string;
|
||||
@@ -25,6 +27,7 @@ type BindPhoneScreenProps = {
|
||||
|
||||
export function BindPhoneScreen({
|
||||
user,
|
||||
platformTheme,
|
||||
sendingCode,
|
||||
binding,
|
||||
error,
|
||||
@@ -54,24 +57,24 @@ export function BindPhoneScreen({
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.14),_transparent_42%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
|
||||
<div className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}>
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-emerald-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-emerald-200/10 bg-[linear-gradient(135deg,_rgba(16,185,129,0.14),_rgba(59,130,246,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-emerald-200/70">
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
|
||||
账号激活
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-[var(--platform-text-strong)] md:text-4xl">
|
||||
绑定手机号
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
|
||||
微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。
|
||||
</p>
|
||||
<div className="mt-8 rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel mt-8 rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录身份:{user.displayName}
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,10 +86,10 @@ export function BindPhoneScreen({
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
@@ -95,11 +98,11 @@ export function BindPhoneScreen({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
className="platform-input min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
@@ -108,7 +111,7 @@ export function BindPhoneScreen({
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-12 shrink-0 rounded-2xl border border-emerald-300/25 px-4 text-sm font-medium text-emerald-100 transition hover:border-emerald-300/55 hover:bg-emerald-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -137,7 +140,7 @@ export function BindPhoneScreen({
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -149,7 +152,7 @@ export function BindPhoneScreen({
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -157,14 +160,14 @@ export function BindPhoneScreen({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={binding || !phone.trim() || !code.trim()}
|
||||
className="h-12 rounded-2xl bg-[linear-gradient(135deg,_#10b981,_#22c55e)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-11 rounded-2xl border border-white/10 px-4 text-sm text-zinc-300 transition hover:border-white/25 hover:text-white"
|
||||
className="platform-button platform-button--ghost h-11 px-4 text-sm"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
|
||||
@@ -16,15 +16,15 @@ export function CaptchaChallengeField({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-4">
|
||||
<div className="text-sm leading-6 text-sky-100">{challenge.promptText}</div>
|
||||
<div className="platform-banner platform-banner--info grid gap-3">
|
||||
<div className="text-sm leading-6">{challenge.promptText}</div>
|
||||
<img
|
||||
src={challenge.imageDataUrl}
|
||||
alt="图形验证码"
|
||||
className="h-14 w-40 rounded-2xl border border-white/10 bg-black/20 object-cover"
|
||||
className="platform-subpanel h-14 w-40 rounded-2xl object-cover"
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-sky-300/45 focus:bg-black/40"
|
||||
className="platform-input h-11"
|
||||
value={answer}
|
||||
placeholder="输入图形验证码"
|
||||
onChange={(event) => onAnswerChange(event.target.value)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
AuthCaptchaChallenge,
|
||||
AuthLoginMethod,
|
||||
@@ -7,12 +9,15 @@ import type {
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type LoginScreenProps = {
|
||||
isOpen: boolean;
|
||||
platformTheme: PlatformTheme;
|
||||
availableLoginMethods: AuthLoginMethod[];
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
wechatLoading: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onClose: () => void;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
@@ -28,12 +33,15 @@ type LoginScreenProps = {
|
||||
};
|
||||
|
||||
export function LoginScreen({
|
||||
isOpen,
|
||||
platformTheme,
|
||||
availableLoginMethods,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
wechatLoading,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onClose,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
onStartWechatLogin,
|
||||
@@ -42,7 +50,6 @@ export function LoginScreen({
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
const phoneLoginEnabled = availableLoginMethods.includes('phone');
|
||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||
|
||||
@@ -60,159 +67,146 @@ export function LoginScreen({
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.08fr_0.92fr]">
|
||||
<div className="border-b border-amber-200/10 bg-[linear-gradient(135deg,_rgba(245,158,11,0.16),_rgba(20,184,166,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-amber-200/70">
|
||||
账号系统
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
账号登录
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
||||
先登录账号,再同步你的冒险进度。
|
||||
</p>
|
||||
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
手机号登录后可在不同设备继续同一份存档
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
验证码登录优先适配移动端,网页端也可直接使用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!phoneLoginEnabled) {
|
||||
return;
|
||||
}
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
<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)]"
|
||||
>
|
||||
{phoneLoginEnabled ? (
|
||||
<>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
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 {
|
||||
setHint('');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled || wechatLoginEnabled ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
|
||||
{phoneLoginEnabled && wechatLoginEnabled
|
||||
? '手机号可直接登录,也可以先用微信。'
|
||||
: phoneLoginEnabled
|
||||
? '当前开放手机号登录。'
|
||||
: '当前开放微信登录。'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled ? (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn || !phone.trim() || !code.trim()}
|
||||
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '正在进入...' : '登录并进入游戏'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={wechatLoading || sendingCode || loggingIn}
|
||||
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void onStartWechatLogin();
|
||||
}}
|
||||
>
|
||||
{wechatLoading ? '正在跳转微信...' : '微信登录'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{!phoneLoginEnabled && !wechatLoginEnabled ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
|
||||
当前登录入口暂不可用,请稍后再试。
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
登录账号
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-icon-button p-2"
|
||||
aria-label="关闭登录弹窗"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</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 () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
// Error state is handled by the parent.
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
</>
|
||||
) : 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}
|
||||
|
||||
{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}
|
||||
|
||||
{!phoneLoginEnabled && !wechatLoginEnabled ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user