This commit is contained in:
2026-04-19 20:33:18 +08:00
parent 692643136f
commit 67c584b4df
123 changed files with 11898 additions and 4082 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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