init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,277 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type {
AuthAuditLogEntry,
AuthRiskBlockSummary,
AuthSessionSummary,
AuthUser,
} from '../../services/authService';
import { AccountModal } from './AccountModal';
const baseUser: AuthUser = {
id: 'user-1',
username: 'tester',
displayName: '138****8000',
publicUserCode: 'user-tester',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: true,
};
function renderAccountModal(overrides?: {
user?: AuthUser;
riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[];
initialSection?:
| 'appearance'
| 'account'
| 'security'
| 'devices'
| 'logs'
| null;
}) {
return render(
<AccountModal
user={overrides?.user ?? baseUser}
isOpen
initialSection={overrides?.initialSection ?? null}
platformTheme="light"
riskBlocks={overrides?.riskBlocks ?? []}
sessions={overrides?.sessions ?? []}
auditLogs={overrides?.auditLogs ?? []}
loadingRiskBlocks={false}
loadingSessions={false}
loadingAuditLogs={false}
isHydratingSettings={false}
isPersistingSettings={false}
settingsError={null}
onClose={vi.fn()}
onPlatformThemeChange={vi.fn()}
onLogout={vi.fn().mockResolvedValue(undefined)}
onRefreshRiskBlocks={vi.fn().mockResolvedValue(undefined)}
onLiftRiskBlock={vi.fn().mockResolvedValue(undefined)}
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
changePhoneCaptchaChallenge={null}
onSendChangePhoneCode={vi.fn().mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
})}
onChangePhone={vi.fn().mockResolvedValue(undefined)}
onChangePassword={vi.fn().mockResolvedValue(undefined)}
/>,
);
}
test('settings header uses a generic title instead of the phone number', () => {
renderAccountModal();
expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy();
expect(screen.getByText('设置与账号安全')).toBeTruthy();
expect(screen.queryByText('138****8000')).toBeNull();
expect(screen.queryByText('选择要管理的内容')).toBeNull();
expect(
screen.queryByText('主题、账号与设备能力统一在独立面板中管理'),
).toBeNull();
expect(screen.queryByText(/^安全状态$/)).toBeNull();
expect(screen.queryByText(/^登录设备$/)).toBeNull();
expect(screen.queryByText(/^操作记录$/)).toBeNull();
expect(screen.queryByText('当前账号状态')).toBeNull();
expect(screen.queryByText('当前主题')).toBeNull();
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
});
test('account actions open in independent panels instead of inline expansion', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
).toBeTruthy();
expect(screen.queryByLabelText('新手机号')).toBeNull();
await user.click(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
});
test('nested settings panels keep back navigation without an extra close action', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
accountHeader?.lastElementChild?.textContent?.includes('返回'),
).toBe(true);
expect(
within(accountDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
await user.click(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
const changePhoneHeader =
changePhoneDialog.firstElementChild as HTMLElement | null;
expect(
within(changePhoneDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
changePhoneHeader?.lastElementChild?.textContent?.includes('返回'),
).toBe(true);
expect(
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
});
test('settings overlays move focus away from inert triggers and restore it on back', async () => {
const user = userEvent.setup();
renderAccountModal();
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
expect(document.activeElement).not.toBe(accountTrigger);
await user.click(accountTrigger);
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountBackButton = within(accountDialog).getByRole('button', {
name: '返回',
});
await waitFor(() => {
expect(document.activeElement).toBe(accountBackButton);
});
const changePhoneTrigger = within(accountDialog).getByRole('button', {
name: '更换手机号',
});
await user.click(changePhoneTrigger);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
const changePhoneBackButton = within(changePhoneDialog).getByRole('button', {
name: '返回',
});
await waitFor(() => {
expect(document.activeElement).toBe(changePhoneBackButton);
});
await user.click(changePhoneBackButton);
await waitFor(() => {
expect(document.activeElement).toBe(changePhoneTrigger);
});
await user.click(accountBackButton);
await waitFor(() => {
expect(document.activeElement).toBe(accountTrigger);
});
});
test('account panel includes merged security devices and audit sections', async () => {
const user = userEvent.setup();
renderAccountModal({
riskBlocks: [
{
scopeType: 'phone',
title: '手机号保护',
detail: '检测到异常验证行为,已开启保护。',
remainingSeconds: 600,
expiresAt: '2026-04-20T10:00:00.000Z',
},
],
sessions: [
{
sessionId: 'session-1',
clientType: 'mobile',
clientRuntime: 'ios',
clientPlatform: 'wechat',
clientLabel: 'iPhone 15 Pro',
deviceDisplayName: 'iPhone 15 Pro / 微信',
miniProgramAppId: null,
miniProgramEnv: null,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)',
isCurrent: true,
createdAt: '2026-04-20T07:30:00.000Z',
lastSeenAt: '2026-04-20T09:00:00.000Z',
expiresAt: '2026-04-27T09:00:00.000Z',
ipMasked: '10.0.*.*',
},
],
auditLogs: [
{
id: 'log-1',
eventType: 'phone_login',
title: '登录成功',
detail: '通过手机号验证码完成登录。',
createdAt: '2026-04-20T08:00:00.000Z',
ipMasked: '10.0.*.*',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)',
},
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '退出登录' }),
).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '退出全部设备' }),
).toBeTruthy();
});
test('legacy nested section requests now open the merged account panel', () => {
renderAccountModal({ initialSection: 'security' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,472 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
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(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
loginWithPhoneCode: vi.fn(),
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
resetPassword: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
consumeAuthCallbackResult: vi.fn(),
}));
vi.mock('../../services/apiClient', () => ({
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
}));
vi.mock('../../services/authService', () => ({
authEntry: authMocks.authEntry,
bindWechatPhone: vi.fn(),
changePassword: authMocks.changePassword,
changePhoneNumber: vi.fn(),
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
getStoredLastLoginPhone: vi.fn(() => ''),
getAuthAuditLogs: vi.fn(),
getAuthLoginOptions: authMocks.getAuthLoginOptions,
getAuthRiskBlocks: vi.fn(),
getCurrentAuthUser: authMocks.getCurrentAuthUser,
getAuthSessions: vi.fn(),
getCaptchaChallengeFromError: vi.fn(() => null),
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser,
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
setStoredLastLoginPhone: vi.fn(),
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', () => ({
AccountModal: () => null,
}));
vi.mock('./BindPhoneScreen', () => ({
BindPhoneScreen: () => <div></div>,
}));
const mockUser: AuthUser = {
id: 'user-1',
username: 'tester',
displayName: '测试玩家',
publicUserCode: 'user-tester',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: false,
};
beforeEach(() => {
vi.clearAllMocks();
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
authMocks.logoutAuthUser.mockResolvedValue(undefined);
authMocks.resetPassword.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
});
authMocks.startWechatLogin.mockResolvedValue(undefined);
});
function ProtectedActionButton({
onAuthenticated,
}: {
onAuthenticated: () => void;
}) {
const authUi = useAuthUi();
return (
<button
type="button"
onClick={() => {
authUi?.requireAuth(onAuthenticated);
}}
>
</button>
);
}
function PlatformTabStateProbe() {
const [tab, setTab] = useState<'home' | 'create'>('home');
return (
<div>
<div>Tab{tab === 'home' ? '首页' : '创作'}</div>
<button type="button" onClick={() => setTab('create')}>
</button>
</div>
);
}
function LogoutStateProbe() {
const authUi = useAuthUi();
return (
<div>
<div>{authUi?.user?.displayName ?? '未登录'}</div>
<div>
{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
</div>
<button
type="button"
onClick={() => {
void authUi?.logout();
}}
>
退
</button>
</div>
);
}
test('auth gate keeps platform content visible when phone login is available', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<div></div>
</AuthGate>,
);
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
});
test('auth gate waits for access token refresh before exposing restored user content', async () => {
let resolveToken!: (token: string) => void;
const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve;
});
authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<div></div>
</AuthGate>,
);
expect(screen.getByText('正在校验登录状态...')).toBeTruthy();
expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled();
resolveToken('jwt-restored-token');
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(authMocks.ensureStoredAccessToken).toHaveBeenCalledTimes(1);
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: [],
});
render(
<AuthGate>
<div></div>
</AuthGate>,
);
expect(await screen.findByText('应用内容')).toBeTruthy();
});
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(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
expect(onAuthenticated).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<PlatformTabStateProbe />
</AuthGate>,
);
expect(await screen.findByText('当前Tab首页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '创作' }));
expect(screen.getByText('当前Tab创作')).toBeTruthy();
let resolveToken!: (token: string) => void;
const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve;
});
authMocks.ensureStoredAccessToken.mockReturnValueOnce(tokenPromise);
act(() => {
window.dispatchEvent(new Event('genarrative-auth-state-changed'));
});
expect(screen.queryByText('正在校验登录状态...')).toBeNull();
expect(screen.getByText('当前Tab创作')).toBeTruthy();
await act(async () => {
resolveToken('jwt-refreshed-token');
await tokenPromise;
});
await waitFor(() => {
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
});
expect(screen.getByText('当前Tab创作')).toBeTruthy();
});
test('logout withdraws user context before backend request finishes', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
let resolveLogout!: () => void;
const logoutPromise = new Promise<void>((resolve) => {
resolveLogout = resolve;
});
authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise);
render(
<AuthGate>
<LogoutStateProbe />
</AuthGate>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '退出登录' }));
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
await act(async () => {
resolveLogout();
await logoutPromise;
});
});
test('auth gate shows sms send feedback in the login modal', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
await waitFor(() => {
expect(authMocks.sendPhoneLoginCode).toHaveBeenCalledWith(
'13800000000',
'login',
{
challengeId: undefined,
answer: '',
},
);
});
expect(
within(dialog).getByText('短信请求已提交,验证码有效期约 5 分钟。'),
).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
});
test('login modal resets draft state every time it is reopened', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'password'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
await user.click(
within(firstDialog).getByRole('button', { name: '获取验证码' }),
);
expect(
await within(firstDialog).findByText(
'短信请求已提交,验证码有效期约 5 分钟。',
),
).toBeTruthy();
await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd');
await user.click(
within(firstDialog).getByRole('button', { name: '忘记密码' }),
);
expect(screen.getByRole('dialog', { name: '重置密码' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '关闭登录弹窗' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
await user.click(screen.getByRole('button', { name: '进入作品' }));
const reopenedDialog = screen.getByRole('dialog', { name: '账号入口' });
expect(
within(reopenedDialog)
.getByRole('tab', { name: '短信登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(
(within(reopenedDialog).getByLabelText('手机号') as HTMLInputElement).value,
).toBe('');
expect(
(within(reopenedDialog).getByLabelText('验证码') as HTMLInputElement).value,
).toBe('');
expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull();
expect(
within(reopenedDialog).queryByText(
'短信请求已提交,验证码有效期约 5 分钟。',
),
).toBeNull();
expect(
within(reopenedDialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
});
test('auth gate separates sms and password login by tabs', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'password'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(
within(dialog)
.getByRole('tab', { name: '短信登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(within(dialog).queryByLabelText('密码')).toBeNull();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(
within(dialog)
.getByRole('tab', { name: '密码登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
});
});

View File

@@ -0,0 +1,763 @@
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useGameSettings } from '../../hooks/useGameSettings';
import {
AUTH_STATE_EVENT,
ensureStoredAccessToken,
} from '../../services/apiClient';
import {
type AuthAuditLogEntry,
type AuthCaptchaChallenge,
authEntry,
type AuthLoginMethod,
type AuthRiskBlockSummary,
type AuthSessionSummary,
type AuthUser,
bindWechatPhone,
changePassword,
changePhoneNumber,
consumeAuthCallbackResult,
getAuthAuditLogs,
getAuthLoginOptions,
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
logoutAuthUser,
resetPassword,
revokeAuthSession,
sendPhoneLoginCode,
setStoredLastLoginPhone,
startWechatLogin,
} from '../../services/authService';
import { AccountModal } from './AccountModal';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
type AuthGateProps = {
children: ReactNode;
};
type AuthStatus =
| 'checking'
| 'recovering'
| 'unauthenticated'
| 'pending_bind_phone'
| 'ready'
| 'error';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
const [availableLoginMethods, setAvailableLoginMethods] = useState<
AuthLoginMethod[]
>([]);
const [error, setError] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [loggingIn, setLoggingIn] = useState(false);
const [bindingPhone, setBindingPhone] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [initialSettingsSection, setInitialSettingsSection] =
useState<PlatformSettingsSection | null>(null);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false);
const [loginCaptchaChallenge, setLoginCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const [bindCaptchaChallenge, setBindCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const hasRenderedPlatformContentRef = useRef(false);
const canKeepPlatformContentMounted =
hasRenderedPlatformContentRef.current &&
(status === 'checking' || status === 'recovering');
const readyUser =
status === 'ready' || canKeepPlatformContentMounted ? user : null;
const settings = useGameSettings(readyUser?.id ?? null);
const platformThemeClass = `platform-theme--${settings.platformTheme}`;
if (status === 'ready' || status === 'unauthenticated') {
hasRenderedPlatformContentRef.current = true;
}
const activateReadyUser = useCallback((nextUser: AuthUser) => {
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
setUser(nextUser);
setStatus('ready');
}, []);
const clearLocalAuthenticatedState = useCallback(() => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowSettingsModal(false);
setInitialSettingsSection(null);
setSessions([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setError('');
}, []);
const logoutCurrentSession = useCallback(async () => {
clearLocalAuthenticatedState();
try {
await logoutAuthUser();
} catch (logoutError) {
setError(
logoutError instanceof Error
? logoutError.message
: '退出登录失败,请刷新页面确认状态。',
);
}
}, [clearLocalAuthenticatedState]);
const logoutAllSessions = useCallback(async () => {
clearLocalAuthenticatedState();
try {
await logoutAllAuthSessions();
} catch (logoutError) {
setError(
logoutError instanceof Error
? logoutError.message
: '退出全部设备失败,请刷新页面确认状态。',
);
}
}, [clearLocalAuthenticatedState]);
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) => {
if (readyUser) {
setInitialSettingsSection(section ?? null);
setShowSettingsModal(true);
return;
}
openLoginModal();
},
[openLoginModal, readyUser],
);
const openAccountModal = useCallback(() => {
openSettingsModal('account');
}, [openSettingsModal]);
useEffect(() => {
let isActive = true;
const hydrate = async () => {
const loadLoginOptions = async () => {
const options = await getAuthLoginOptions();
if (!isActive) {
return null;
}
setAvailableLoginMethods(options.availableLoginMethods);
return options;
};
const resolveGuestFallback = async () => {
try {
const options = await loadLoginOptions();
if (!isActive) {
return;
}
setUser(null);
setStatus('unauthenticated');
} catch (optionsError) {
if (!isActive) {
return;
}
setAvailableLoginMethods([]);
setUser(null);
setError(
optionsError instanceof Error
? optionsError.message
: '读取登录方式失败,请稍后再试。',
);
setStatus('unauthenticated');
}
};
const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) {
setError(callbackResult.error);
setShowLoginModal(true);
}
try {
await ensureStoredAccessToken();
const nextSession = await getCurrentAuthUser();
if (!isActive) {
return;
}
if (!nextSession.user) {
setAvailableLoginMethods(nextSession.availableLoginMethods);
await resolveGuestFallback();
return;
}
setUser(nextSession.user);
setAvailableLoginMethods(nextSession.availableLoginMethods);
setStatus(
nextSession.user.bindingStatus === 'pending_bind_phone'
? 'pending_bind_phone'
: 'ready',
);
setError(callbackResult?.error ?? '');
} catch {
if (!isActive) {
return;
}
await resolveGuestFallback();
}
};
void hydrate();
const handleAuthStateChange = () => {
setStatus('checking');
void hydrate();
};
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
return () => {
isActive = false;
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
};
}, [activateReadyUser]);
useEffect(() => {
if (!readyUser) {
setShowSettingsModal(false);
return;
}
setShowLoginModal(false);
const pendingAction = pendingProtectedActionRef.current;
pendingProtectedActionRef.current = null;
pendingAction?.();
}, [readyUser]);
useEffect(() => {
if (!showSettingsModal || status !== 'ready') {
return;
}
let isActive = true;
setLoadingRiskBlocks(true);
setLoadingSessions(true);
setLoadingAuditLogs(true);
void getAuthRiskBlocks()
.then((nextBlocks) => {
if (!isActive) {
return;
}
setRiskBlocks(nextBlocks);
})
.catch((blockError) => {
if (!isActive) {
return;
}
setError(
blockError instanceof Error
? blockError.message
: '读取安全状态失败,请稍后再试。',
);
})
.finally(() => {
if (!isActive) {
return;
}
setLoadingRiskBlocks(false);
});
void getAuthSessions()
.then((nextSessions) => {
if (!isActive) {
return;
}
setSessions(nextSessions);
})
.catch((sessionError) => {
if (!isActive) {
return;
}
setError(
sessionError instanceof Error
? sessionError.message
: '读取登录设备失败,请稍后再试。',
);
})
.finally(() => {
if (!isActive) {
return;
}
setLoadingSessions(false);
});
void getAuthAuditLogs()
.then((nextLogs) => {
if (!isActive) {
return;
}
setAuditLogs(nextLogs);
})
.catch((auditError) => {
if (!isActive) {
return;
}
setError(
auditError instanceof Error
? auditError.message
: '读取账号操作记录失败,请稍后再试。',
);
})
.finally(() => {
if (!isActive) {
return;
}
setLoadingAuditLogs(false);
});
return () => {
isActive = false;
};
}, [showSettingsModal, status]);
const authUiValue = useMemo(
() => ({
user: readyUser,
// 平台内容在 checking/recovering 阶段可以继续挂载,避免闪烁;
// 但受保护请求只能在真实 ready 且存在用户时再启动。
canAccessProtectedData: status === 'ready' && Boolean(readyUser),
openLoginModal,
requireAuth,
openSettingsModal,
openAccountModal,
logout: logoutCurrentSession,
musicVolume: settings.musicVolume,
setMusicVolume: settings.setMusicVolume,
platformTheme: settings.platformTheme,
setPlatformTheme: settings.setPlatformTheme,
isHydratingSettings: settings.isHydratingSettings,
isPersistingSettings: settings.isPersistingSettings,
settingsError: settings.settingsError,
}),
[
openAccountModal,
openLoginModal,
openSettingsModal,
readyUser,
requireAuth,
logoutCurrentSession,
status,
settings.isHydratingSettings,
settings.isPersistingSettings,
settings.musicVolume,
settings.platformTheme,
settings.setMusicVolume,
settings.setPlatformTheme,
settings.settingsError,
],
);
if (status === 'checking' && !canKeepPlatformContentMounted) {
return (
<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 === 'recovering' && !canKeepPlatformContentMounted) {
return (
<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 === 'pending_bind_phone' && user) {
return (
<BindPhoneScreen
user={user}
platformTheme={settings.platformTheme}
sendingCode={sendingCode}
binding={bindingPhone}
error={error}
captchaChallenge={bindCaptchaChallenge}
onSendCode={async (phone, captcha) => {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(
phone,
'bind_phone',
captcha,
);
setBindCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setBindCaptchaChallenge(captchaChallenge);
}
setError(
sendError instanceof Error
? sendError.message
: '发送验证码失败,请稍后再试。',
);
throw sendError;
} finally {
setSendingCode(false);
}
}}
onSubmit={async (phone, code) => {
setBindingPhone(true);
setError('');
try {
const nextUser = await bindWechatPhone(phone, code);
setBindCaptchaChallenge(null);
activateReadyUser(nextUser);
} catch (bindError) {
setError(
bindError instanceof Error
? bindError.message
: '绑定手机号失败,请稍后再试。',
);
} finally {
setBindingPhone(false);
}
}}
onLogout={async () => {
await logoutCurrentSession();
}}
/>
);
}
if (
status !== 'ready' &&
status !== 'unauthenticated' &&
!canKeepPlatformContentMounted
) {
return (
<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="platform-button platform-button--primary mt-5"
onClick={() => {
window.location.reload();
}}
>
</button>
</div>
</div>
);
}
return (
<AuthUiContext.Provider value={authUiValue}>
<div className="relative">
<div className={`platform-theme ${platformThemeClass}`}>
{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={logoutCurrentSession}
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={logoutAllSessions}
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);
}}
onChangePassword={async (currentPassword, newPassword) => {
const nextUser = await changePassword(
currentPassword,
newPassword,
);
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, scene, captcha) => {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, scene, 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);
}
}}
onPhoneSubmit={async (phone, code) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await loginWithPhoneCode(phone, code);
setStoredLastLoginPhone(phone);
setLoginCaptchaChallenge(null);
activateReadyUser(nextUser);
} catch (loginError) {
setError(
loginError instanceof Error
? loginError.message
: '登录失败,请稍后再试。',
);
} finally {
setLoggingIn(false);
}
}}
onPasswordSubmit={async (phone, password) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await authEntry(phone, password);
setStoredLastLoginPhone(phone);
activateReadyUser(nextUser);
} catch (loginError) {
setError(
loginError instanceof Error
? loginError.message
: '登录失败,请稍后再试。',
);
} finally {
setLoggingIn(false);
}
}}
onResetPassword={async (phone, code, newPassword) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await resetPassword(phone, code, newPassword);
setStoredLastLoginPhone(phone);
activateReadyUser(nextUser);
} catch (resetError) {
setError(
resetError instanceof Error
? resetError.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

@@ -0,0 +1,34 @@
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;
canAccessProtectedData: boolean;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
logout: () => Promise<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);
export function useAuthUi() {
return useContext(AuthUiContext);
}

View File

@@ -0,0 +1,182 @@
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;
captchaChallenge: AuthCaptchaChallenge | null;
onSendCode: (
phone: string,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onSubmit: (phone: string, code: string) => Promise<void>;
onLogout: () => Promise<void>;
};
export function BindPhoneScreen({
user,
platformTheme,
sendingCode,
binding,
error,
captchaChallenge,
onSendCode,
onSubmit,
onLogout,
}: BindPhoneScreenProps) {
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [cooldownSeconds]);
return (
<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="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-[var(--platform-cool-text)]">
</p>
<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-[var(--platform-text-base)]">
</p>
<div className="platform-subpanel mt-8 rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
{user.displayName}
</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();
void onSubmit(phone, code);
}}
>
<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);
setHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch {
setHint('');
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{hint ? (
<div className="platform-banner platform-banner--success text-sm">
{hint}
</div>
) : null}
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
{error ? (
<div className="platform-banner platform-banner--danger text-sm">
{error}
</div>
) : null}
<button
type="submit"
disabled={binding || !phone.trim() || !code.trim()}
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="platform-button platform-button--ghost h-11 px-4 text-sm"
onClick={() => {
void onLogout();
}}
>
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { AuthCaptchaChallenge } from '../../services/authService';
type CaptchaChallengeFieldProps = {
challenge: AuthCaptchaChallenge | null;
answer: string;
onAnswerChange: (value: string) => void;
};
export function CaptchaChallengeField({
challenge,
answer,
onAnswerChange,
}: CaptchaChallengeFieldProps) {
if (!challenge) {
return null;
}
return (
<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="platform-subpanel h-14 w-40 rounded-2xl object-cover"
/>
<input
className="platform-input h-11"
value={answer}
placeholder="输入图形验证码"
onChange={(event) => onAnswerChange(event.target.value)}
/>
</div>
);
}

View File

@@ -0,0 +1,609 @@
import { X } from 'lucide-react';
import { 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 { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password';
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 passwordLoginEnabled = availableLoginMethods.includes('password');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
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('');
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;
return (
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
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)]"
>
{isResetPanelOpen ? '重置密码' : '账号入口'}
</div>
<button
type="button"
onClick={onClose}
className="platform-icon-button p-2"
aria-label="关闭登录弹窗"
>
<X className="h-4 w-4" />
</button>
</div>
{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 && passwordLoginEnabled ? (
<div
className="grid grid-cols-2 gap-2"
role="tablist"
aria-label="登录方式"
>
<LoginTabButton
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
>
</LoginTabButton>
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
</div>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void onPasswordSubmit(phone, password);
}}
>
<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>
<input
className="platform-input"
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
{error ? <ErrorBanner message={error} /> : null}
<div className="flex flex-col gap-2">
<button
type="submit"
disabled={
submitDisabled || !phone.trim() || !password.trim()
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '登录中' : '登录'}
</button>
<button
type="button"
className="self-end text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
</div>
{wechatLoginEnabled ? (
<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}
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 ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
</div>
) : null}
</div>
)}
</div>
</div>
);
}
function LoginTabButton({
active,
children,
onClick,
}: {
active: boolean;
children: string;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
active
? 'text-[var(--platform-text-strong)]'
: 'text-[var(--platform-text-muted)]'
}`}
onClick={onClick}
>
<span>{children}</span>
{active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</button>
);
}
function PhoneCodeForm({
phone,
code,
captchaAnswer,
captchaChallenge,
cooldownSeconds,
sendingCode,
loggingIn,
error,
hint,
submitLabel,
enabled,
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;
showPhoneField: boolean;
onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void;
onCaptchaAnswerChange: (value: string) => void;
onSendCode: () => Promise<void>;
onSubmit: () => Promise<void>;
}) {
if (!enabled) {
return null;
}
return (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void onSubmit();
}}
>
{showPhoneField ? (
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => onPhoneChange(event.target.value)}
placeholder="13800000000"
/>
</label>
) : null}
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<div className="flex gap-3">
<input
className="platform-input min-w-0 flex-1"
inputMode="numeric"
value={code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => void onSendCode()}
>
{sendingCode
? '发送中'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={onCaptchaAnswerChange}
/>
{hint ? <SuccessBanner message={hint} /> : null}
{error ? <ErrorBanner message={error} /> : null}
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim()}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '处理中' : submitLabel}
</button>
</form>
);
}
function PasswordResetPanel({
phone,
code,
password,
sendingCode,
loggingIn,
cooldownSeconds,
error,
onPhoneChange,
onCodeChange,
onPasswordChange,
onBack,
onSendCode,
onSubmit,
}: {
phone: string;
code: string;
password: string;
sendingCode: boolean;
loggingIn: boolean;
cooldownSeconds: number;
error: string;
onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onBack: () => void;
onSendCode: () => Promise<void>;
onSubmit: () => Promise<void>;
}) {
return (
<form
className="flex flex-col gap-4 px-5 py-5"
onSubmit={(event) => {
event.preventDefault();
void onSubmit();
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => onPhoneChange(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<div className="flex gap-3">
<input
className="platform-input min-w-0 flex-1"
inputMode="numeric"
value={code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => void onSendCode()}
>
{sendingCode
? '发送中'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="new-password"
type="password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
placeholder="设置新密码"
/>
</label>
{error ? <ErrorBanner message={error} /> : null}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="platform-button platform-button--secondary h-12 px-4 text-base"
onClick={onBack}
>
</button>
<button
type="submit"
disabled={
loggingIn || !phone.trim() || !code.trim() || !password.trim()
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '处理中' : '重置密码'}
</button>
</div>
</form>
);
}
function WechatButton({
loading,
disabled,
onClick,
}: {
loading: boolean;
disabled: boolean;
onClick: () => Promise<void>;
}) {
return (
<button
type="button"
disabled={loading || disabled}
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void onClick()}
>
{loading ? '跳转中' : '微信登录'}
</button>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="platform-banner platform-banner--danger text-sm">
{message}
</div>
);
}
function SuccessBanner({ message }: { message: string }) {
return (
<div className="platform-banner platform-banner--success text-sm">
{message}
</div>
);
}