Merge remote-tracking branch 'origin/master'
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -22,6 +22,7 @@ const baseUser: AuthUser = {
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
function renderAccountModal(overrides?: {
|
||||
|
||||
@@ -18,6 +18,7 @@ const authMocks = vi.hoisted(() => ({
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
redeemRegistrationInviteCode: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
@@ -46,6 +47,7 @@ vi.mock('../../services/authService', () => ({
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSession: vi.fn(),
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
@@ -84,6 +86,7 @@ const mockUser: AuthUser = {
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -105,6 +108,25 @@ beforeEach(() => {
|
||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
|
||||
authMocks.logoutAuthUser.mockResolvedValue(undefined);
|
||||
authMocks.redeemRegistrationInviteCode.mockResolvedValue({
|
||||
center: {
|
||||
inviteCode: 'SY12345678',
|
||||
inviteLinkPath: '/?inviteCode=SY12345678',
|
||||
invitedCount: 1,
|
||||
rewardedInviteCount: 1,
|
||||
todayInviterRewardCount: 0,
|
||||
todayInviterRewardRemaining: 3,
|
||||
rewardPoints: 30,
|
||||
hasRedeemedCode: true,
|
||||
boundInviterUserId: 'user_inviter',
|
||||
boundAt: '2026-05-01T00:00:00Z',
|
||||
updatedAt: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
inviteeRewardGranted: true,
|
||||
inviterRewardGranted: true,
|
||||
inviteeBalanceAfter: 30,
|
||||
inviterBalanceAfter: 30,
|
||||
});
|
||||
authMocks.resetPassword.mockResolvedValue(mockUser);
|
||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
@@ -251,7 +273,9 @@ test('auth gate keeps password entry available when login options are empty', as
|
||||
test('auth gate falls back to password entry when login options request fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockRejectedValue(new Error('读取登录方式失败'));
|
||||
authMocks.getAuthLoginOptions.mockRejectedValue(
|
||||
new Error('读取登录方式失败'),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
@@ -294,7 +318,6 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
|
||||
'13800000000',
|
||||
'123456',
|
||||
undefined,
|
||||
);
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
||||
@@ -303,44 +326,98 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
test('auth gate opens register tab and preloads invite code from url', async () => {
|
||||
test('auth gate hides register entry and opens invite modal for new sms account', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
|
||||
token: 'jwt-phone-new',
|
||||
user: mockUser,
|
||||
created: true,
|
||||
referral: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>公开内容</div>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('tab', { name: '注册' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
});
|
||||
expect(
|
||||
(within(dialog).getByLabelText('邀请码') as HTMLInputElement).value,
|
||||
).toBe('SPRING2026');
|
||||
expect(await screen.findByText('公开内容')).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '进入作品' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
|
||||
expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull();
|
||||
expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(dialog).getByRole('button', { name: '注册' }));
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
|
||||
'13800000000',
|
||||
'123456',
|
||||
);
|
||||
});
|
||||
|
||||
const inviteDialog = await screen.findByRole('dialog', {
|
||||
name: '请填写邀请码',
|
||||
});
|
||||
expect(
|
||||
(within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value,
|
||||
).toBe('SPRING2026');
|
||||
expect(
|
||||
within(inviteDialog).getByRole('button', { name: '提交' }),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(within(inviteDialog).getByRole('button', { name: '提交' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.redeemRegistrationInviteCode).toHaveBeenCalledWith(
|
||||
'SPRING2026',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('registration invite modal can skip when invite code is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
|
||||
token: 'jwt-phone-new',
|
||||
user: mockUser,
|
||||
created: true,
|
||||
referral: null,
|
||||
});
|
||||
|
||||
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.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
const inviteDialog = await screen.findByRole('dialog', {
|
||||
name: '请填写邀请码',
|
||||
});
|
||||
await user.click(within(inviteDialog).getByRole('button', { name: '跳过' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '请填写邀请码' })).toBeNull();
|
||||
});
|
||||
expect(authMocks.redeemRegistrationInviteCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth state refresh keeps mounted platform content and local tab state', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
redeemRegistrationInviteCode,
|
||||
resetPassword,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
@@ -44,6 +45,7 @@ import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
import { RegistrationInviteModal } from './RegistrationInviteModal';
|
||||
|
||||
type AuthGateProps = {
|
||||
children: ReactNode;
|
||||
@@ -91,10 +93,12 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [bindingPhone, setBindingPhone] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [loginInitialMode, setLoginInitialMode] = useState<
|
||||
'login' | 'register'
|
||||
>('login');
|
||||
const [pendingInviteCode, setPendingInviteCode] = useState('');
|
||||
const [showRegistrationInviteModal, setShowRegistrationInviteModal] =
|
||||
useState(false);
|
||||
const [submittingRegistrationInvite, setSubmittingRegistrationInvite] =
|
||||
useState(false);
|
||||
const [registrationInviteError, setRegistrationInviteError] = useState('');
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [settingsEntryMode, setSettingsEntryMode] = useState<
|
||||
'settings' | 'account'
|
||||
@@ -141,6 +145,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
setShowLoginModal(false);
|
||||
setShowRegistrationInviteModal(false);
|
||||
setShowSettingsModal(false);
|
||||
setSettingsEntryMode('settings');
|
||||
setInitialSettingsSection(null);
|
||||
@@ -150,6 +155,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoginCaptchaChallenge(null);
|
||||
setBindCaptchaChallenge(null);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setPendingInviteCode('');
|
||||
setRegistrationInviteError('');
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
@@ -182,12 +189,16 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const closeLoginModal = useCallback(() => {
|
||||
pendingProtectedActionRef.current = null;
|
||||
setShowLoginModal(false);
|
||||
setLoginInitialMode('login');
|
||||
setPendingInviteCode('');
|
||||
setLoginCaptchaChallenge(null);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const closeRegistrationInviteModal = useCallback(() => {
|
||||
setShowRegistrationInviteModal(false);
|
||||
setRegistrationInviteError('');
|
||||
setPendingInviteCode('');
|
||||
}, []);
|
||||
|
||||
const closeSettingsModal = useCallback(() => {
|
||||
setShowSettingsModal(false);
|
||||
setSettingsEntryMode('settings');
|
||||
@@ -202,8 +213,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
pendingProtectedActionRef.current = postLoginAction ?? null;
|
||||
setLoginInitialMode('login');
|
||||
setPendingInviteCode('');
|
||||
setShowLoginModal(true);
|
||||
},
|
||||
[readyUser],
|
||||
@@ -253,10 +262,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return;
|
||||
}
|
||||
autoOpenedInviteCodeRef.current = inviteCode;
|
||||
pendingProtectedActionRef.current = null;
|
||||
setPendingInviteCode(inviteCode);
|
||||
setLoginInitialMode('register');
|
||||
setShowLoginModal(true);
|
||||
}, [readyUser, showLoginModal, status]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -736,8 +742,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
wechatLoading={wechatLoading}
|
||||
error={error}
|
||||
captchaChallenge={loginCaptchaChallenge}
|
||||
initialMode={loginInitialMode}
|
||||
initialInviteCode={pendingInviteCode}
|
||||
onClose={closeLoginModal}
|
||||
onSendCode={async (phone, scene, captcha) => {
|
||||
setSendingCode(true);
|
||||
@@ -762,20 +766,15 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onPhoneSubmit={async (phone, code, inviteCode) => {
|
||||
onPhoneSubmit={async (phone, code) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await loginWithPhoneCode(
|
||||
phone,
|
||||
code,
|
||||
inviteCode,
|
||||
);
|
||||
const response = await loginWithPhoneCode(phone, code);
|
||||
setStoredLastLoginPhone(phone);
|
||||
setLoginCaptchaChallenge(null);
|
||||
if (response.referral && !response.referral.ok) {
|
||||
setError(response.referral.message || '邀请码未绑定');
|
||||
}
|
||||
setShowRegistrationInviteModal(response.created);
|
||||
setRegistrationInviteError('');
|
||||
activateReadyUser(response.user);
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
@@ -837,6 +836,30 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<RegistrationInviteModal
|
||||
isOpen={showRegistrationInviteModal}
|
||||
platformTheme={settings.platformTheme}
|
||||
initialInviteCode={pendingInviteCode}
|
||||
submitting={submittingRegistrationInvite}
|
||||
error={registrationInviteError}
|
||||
onClose={closeRegistrationInviteModal}
|
||||
onSubmit={async (inviteCode) => {
|
||||
setSubmittingRegistrationInvite(true);
|
||||
setRegistrationInviteError('');
|
||||
try {
|
||||
await redeemRegistrationInviteCode(inviteCode);
|
||||
closeRegistrationInviteModal();
|
||||
} catch (inviteError) {
|
||||
setRegistrationInviteError(
|
||||
inviteError instanceof Error
|
||||
? inviteError.message
|
||||
: '填写邀请码失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setSubmittingRegistrationInvite(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function BindPhoneScreen({
|
||||
<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__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)]">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type SmsScene = 'login' | 'reset_password';
|
||||
type LoginTab = 'phone' | 'password' | 'register';
|
||||
type LoginTab = 'phone' | 'password';
|
||||
|
||||
type LoginScreenProps = {
|
||||
isOpen: boolean;
|
||||
@@ -21,8 +21,6 @@ type LoginScreenProps = {
|
||||
wechatLoading: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
initialMode?: 'login' | 'register';
|
||||
initialInviteCode?: string;
|
||||
onClose: () => void;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
@@ -35,11 +33,7 @@ type LoginScreenProps = {
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onPhoneSubmit: (
|
||||
phone: string,
|
||||
code: string,
|
||||
inviteCode?: string,
|
||||
) => Promise<void>;
|
||||
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
|
||||
onResetPassword: (
|
||||
phone: string,
|
||||
@@ -58,8 +52,6 @@ export function LoginScreen({
|
||||
wechatLoading,
|
||||
error,
|
||||
captchaChallenge,
|
||||
initialMode = 'login',
|
||||
initialInviteCode = '',
|
||||
onClose,
|
||||
onSendCode,
|
||||
onPhoneSubmit,
|
||||
@@ -74,7 +66,6 @@ export function LoginScreen({
|
||||
const [resetPhone, setResetPhone] = useState('');
|
||||
const [resetCode, setResetCode] = useState('');
|
||||
const [resetPasswordValue, setResetPasswordValue] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState(initialInviteCode);
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
|
||||
@@ -97,23 +88,16 @@ export function LoginScreen({
|
||||
setResetPhone('');
|
||||
setResetCode('');
|
||||
setResetPasswordValue('');
|
||||
setInviteCode(initialInviteCode);
|
||||
setCaptchaAnswer('');
|
||||
setCooldownSeconds(0);
|
||||
setResetCooldownSeconds(0);
|
||||
setHint('');
|
||||
setActiveLoginTab(
|
||||
initialMode === 'register' && phoneLoginEnabled
|
||||
? 'register'
|
||||
: phoneLoginEnabled
|
||||
? 'phone'
|
||||
: 'password',
|
||||
);
|
||||
}, [initialInviteCode, initialMode, isOpen, phoneLoginEnabled]);
|
||||
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
|
||||
}, [isOpen, phoneLoginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(activeLoginTab === 'phone' || activeLoginTab === 'register') &&
|
||||
activeLoginTab === 'phone' &&
|
||||
!phoneLoginEnabled &&
|
||||
passwordLoginEnabled
|
||||
) {
|
||||
@@ -215,7 +199,7 @@ export function LoginScreen({
|
||||
{phoneLoginEnabled ? (
|
||||
<div
|
||||
className={`grid gap-2 ${
|
||||
passwordLoginEnabled ? 'grid-cols-3' : 'grid-cols-2'
|
||||
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
|
||||
}`}
|
||||
role="tablist"
|
||||
aria-label="登录方式"
|
||||
@@ -234,12 +218,6 @@ export function LoginScreen({
|
||||
密码登录
|
||||
</LoginTabButton>
|
||||
) : null}
|
||||
<LoginTabButton
|
||||
active={activeLoginTab === 'register'}
|
||||
onClick={() => setActiveLoginTab('register')}
|
||||
>
|
||||
注册
|
||||
</LoginTabButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -338,42 +316,6 @@ export function LoginScreen({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled && activeLoginTab === 'register' ? (
|
||||
<PhoneCodeForm
|
||||
phone={phone}
|
||||
code={code}
|
||||
inviteCode={inviteCode}
|
||||
captchaAnswer={captchaAnswer}
|
||||
captchaChallenge={captchaChallenge}
|
||||
cooldownSeconds={cooldownSeconds}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
error={error}
|
||||
hint={hint}
|
||||
submitLabel="注册"
|
||||
enabled={phoneLoginEnabled}
|
||||
showPhoneField
|
||||
showInviteCodeField
|
||||
onPhoneChange={setPhone}
|
||||
onCodeChange={setCode}
|
||||
onInviteCodeChange={setInviteCode}
|
||||
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, inviteCode)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!passwordLoginEnabled &&
|
||||
!phoneLoginEnabled &&
|
||||
!wechatLoginEnabled ? (
|
||||
@@ -420,7 +362,6 @@ function LoginTabButton({
|
||||
function PhoneCodeForm({
|
||||
phone,
|
||||
code,
|
||||
inviteCode = '',
|
||||
captchaAnswer,
|
||||
captchaChallenge,
|
||||
cooldownSeconds,
|
||||
@@ -431,17 +372,14 @@ function PhoneCodeForm({
|
||||
submitLabel,
|
||||
enabled,
|
||||
showPhoneField,
|
||||
showInviteCodeField = false,
|
||||
onPhoneChange,
|
||||
onCodeChange,
|
||||
onInviteCodeChange,
|
||||
onCaptchaAnswerChange,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
}: {
|
||||
phone: string;
|
||||
code: string;
|
||||
inviteCode?: string;
|
||||
captchaAnswer: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
cooldownSeconds: number;
|
||||
@@ -452,10 +390,8 @@ function PhoneCodeForm({
|
||||
submitLabel: string;
|
||||
enabled: boolean;
|
||||
showPhoneField: boolean;
|
||||
showInviteCodeField?: boolean;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onCodeChange: (value: string) => void;
|
||||
onInviteCodeChange?: (value: string) => void;
|
||||
onCaptchaAnswerChange: (value: string) => void;
|
||||
onSendCode: () => Promise<void>;
|
||||
onSubmit: () => Promise<void>;
|
||||
@@ -486,19 +422,6 @@ function PhoneCodeForm({
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{showInviteCodeField ? (
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>邀请码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="off"
|
||||
value={inviteCode}
|
||||
onChange={(event) => onInviteCodeChange?.(event.target.value)}
|
||||
placeholder="邀请码"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
|
||||
115
src/components/auth/RegistrationInviteModal.tsx
Normal file
115
src/components/auth/RegistrationInviteModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
type RegistrationInviteModalProps = {
|
||||
isOpen: boolean;
|
||||
platformTheme: PlatformTheme;
|
||||
initialInviteCode: string;
|
||||
submitting: boolean;
|
||||
error: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (inviteCode: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function RegistrationInviteModal({
|
||||
isOpen,
|
||||
platformTheme,
|
||||
initialInviteCode,
|
||||
submitting,
|
||||
error,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: RegistrationInviteModalProps) {
|
||||
const [inviteCode, setInviteCode] = useState(initialInviteCode);
|
||||
const normalizedInviteCode = useMemo(
|
||||
() =>
|
||||
inviteCode
|
||||
.trim()
|
||||
.replace(/[^0-9a-z]/gi, '')
|
||||
.toUpperCase(),
|
||||
[inviteCode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInviteCode(initialInviteCode);
|
||||
}, [initialInviteCode, isOpen]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[130] 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="registration-invite-dialog-title"
|
||||
className="platform-auth-card w-full max-w-sm 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="registration-invite-dialog-title"
|
||||
className="text-lg font-semibold text-[var(--platform-text-strong)]"
|
||||
>
|
||||
请填写邀请码
|
||||
</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 (!normalizedInviteCode) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
void onSubmit(normalizedInviteCode);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>邀请码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="off"
|
||||
value={inviteCode}
|
||||
onChange={(event) => setInviteCode(event.target.value)}
|
||||
placeholder="邀请码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user