This commit is contained in:
2026-05-01 01:53:14 +08:00
69 changed files with 7346 additions and 759 deletions

View File

@@ -88,13 +88,19 @@ const mockUser: AuthUser = {
beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.loginWithPhoneCode.mockResolvedValue({
token: 'jwt-phone',
user: mockUser,
created: false,
referral: null,
});
authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
@@ -287,6 +293,7 @@ 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);
@@ -295,6 +302,44 @@ 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 () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<div></div>
</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');
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',
'SPRING2026',
);
});
});
test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({

View File

@@ -59,6 +59,14 @@ type AuthStatus =
const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password'];
function readInviteCodeFromLocation(): string {
const params = new URLSearchParams(window.location.search || '');
return (params.get('inviteCode') || params.get('invite_code') || '')
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
}
function normalizeAvailableLoginMethods(
methods: AuthLoginMethod[] | null | undefined,
): AuthLoginMethod[] {
@@ -83,6 +91,10 @@ 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 [showSettingsModal, setShowSettingsModal] = useState(false);
const [settingsEntryMode, setSettingsEntryMode] = useState<
'settings' | 'account'
@@ -102,6 +114,7 @@ export function AuthGate({ children }: AuthGateProps) {
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const autoOpenedInviteCodeRef = useRef<string | null>(null);
const hasRenderedPlatformContentRef = useRef(false);
const canKeepPlatformContentMounted =
hasRenderedPlatformContentRef.current &&
@@ -169,6 +182,8 @@ export function AuthGate({ children }: AuthGateProps) {
const closeLoginModal = useCallback(() => {
pendingProtectedActionRef.current = null;
setShowLoginModal(false);
setLoginInitialMode('login');
setPendingInviteCode('');
setLoginCaptchaChallenge(null);
setError('');
}, []);
@@ -187,6 +202,8 @@ export function AuthGate({ children }: AuthGateProps) {
}
pendingProtectedActionRef.current = postLoginAction ?? null;
setLoginInitialMode('login');
setPendingInviteCode('');
setShowLoginModal(true);
},
[readyUser],
@@ -224,6 +241,24 @@ export function AuthGate({ children }: AuthGateProps) {
openLoginModal();
}, [openLoginModal, readyUser]);
useEffect(() => {
if (status !== 'unauthenticated' || readyUser || showLoginModal) {
return;
}
const inviteCode = readInviteCodeFromLocation();
if (!inviteCode) {
return;
}
if (autoOpenedInviteCodeRef.current === inviteCode) {
return;
}
autoOpenedInviteCodeRef.current = inviteCode;
pendingProtectedActionRef.current = null;
setPendingInviteCode(inviteCode);
setLoginInitialMode('register');
setShowLoginModal(true);
}, [readyUser, showLoginModal, status]);
useEffect(() => {
let isActive = true;
@@ -703,6 +738,8 @@ export function AuthGate({ children }: AuthGateProps) {
wechatLoading={wechatLoading}
error={error}
captchaChallenge={loginCaptchaChallenge}
initialMode={loginInitialMode}
initialInviteCode={pendingInviteCode}
onClose={closeLoginModal}
onSendCode={async (phone, scene, captcha) => {
setSendingCode(true);
@@ -727,14 +764,21 @@ export function AuthGate({ children }: AuthGateProps) {
setSendingCode(false);
}
}}
onPhoneSubmit={async (phone, code) => {
onPhoneSubmit={async (phone, code, inviteCode) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await loginWithPhoneCode(phone, code);
const response = await loginWithPhoneCode(
phone,
code,
inviteCode,
);
setStoredLastLoginPhone(phone);
setLoginCaptchaChallenge(null);
activateReadyUser(nextUser);
if (response.referral && !response.referral.ok) {
setError(response.referral.message || '邀请码未绑定');
}
activateReadyUser(response.user);
} catch (loginError) {
setError(
loginError instanceof Error

View File

@@ -10,7 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password';
type LoginTab = 'phone' | 'password' | 'register';
type LoginScreenProps = {
isOpen: boolean;
@@ -21,6 +21,8 @@ type LoginScreenProps = {
wechatLoading: boolean;
error: string;
captchaChallenge: AuthCaptchaChallenge | null;
initialMode?: 'login' | 'register';
initialInviteCode?: string;
onClose: () => void;
onSendCode: (
phone: string,
@@ -33,7 +35,11 @@ type LoginScreenProps = {
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
onPhoneSubmit: (
phone: string,
code: string,
inviteCode?: string,
) => Promise<void>;
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
onResetPassword: (
phone: string,
@@ -52,6 +58,8 @@ export function LoginScreen({
wechatLoading,
error,
captchaChallenge,
initialMode = 'login',
initialInviteCode = '',
onClose,
onSendCode,
onPhoneSubmit,
@@ -66,6 +74,7 @@ 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);
@@ -88,16 +97,23 @@ export function LoginScreen({
setResetPhone('');
setResetCode('');
setResetPasswordValue('');
setInviteCode(initialInviteCode);
setCaptchaAnswer('');
setCooldownSeconds(0);
setResetCooldownSeconds(0);
setHint('');
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
}, [isOpen, phoneLoginEnabled]);
setActiveLoginTab(
initialMode === 'register' && phoneLoginEnabled
? 'register'
: phoneLoginEnabled
? 'phone'
: 'password',
);
}, [initialInviteCode, initialMode, isOpen, phoneLoginEnabled]);
useEffect(() => {
if (
activeLoginTab === 'phone' &&
(activeLoginTab === 'phone' || activeLoginTab === 'register') &&
!phoneLoginEnabled &&
passwordLoginEnabled
) {
@@ -196,9 +212,11 @@ export function LoginScreen({
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled && passwordLoginEnabled ? (
{phoneLoginEnabled ? (
<div
className="grid grid-cols-2 gap-2"
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-3' : 'grid-cols-2'
}`}
role="tablist"
aria-label="登录方式"
>
@@ -208,11 +226,19 @@ export function LoginScreen({
>
</LoginTabButton>
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
) : null}
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
active={activeLoginTab === 'register'}
onClick={() => setActiveLoginTab('register')}
>
</LoginTabButton>
</div>
) : null}
@@ -312,6 +338,42 @@ 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 ? (
@@ -358,6 +420,7 @@ function LoginTabButton({
function PhoneCodeForm({
phone,
code,
inviteCode = '',
captchaAnswer,
captchaChallenge,
cooldownSeconds,
@@ -368,14 +431,17 @@ 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;
@@ -386,8 +452,10 @@ 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>;
@@ -418,6 +486,19 @@ 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">

View File

@@ -48,7 +48,6 @@ import type {
ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary,
ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
@@ -58,7 +57,6 @@ import { copyTextToClipboard } from '../../services/clipboard';
import {
getRpgProfileReferralInviteCenter,
getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
@@ -1934,34 +1932,21 @@ function RewardCodeRedeemModal({
function ProfileReferralModal({
panel,
center,
inviteCodeInput,
isLoading,
isSubmitting,
error,
success,
onClose,
onInputChange,
onCopyInvite,
onSubmitRedeem,
}: {
panel: ProfilePopupPanel;
center: ProfileReferralInviteCenterResponse | null;
inviteCodeInput: string;
isLoading: boolean;
isSubmitting: boolean;
error: string | null;
success: string | null;
onClose: () => void;
onInputChange: (value: string) => void;
onCopyInvite: () => void;
onSubmitRedeem: () => void;
}) {
const title =
panel === 'invite'
? '邀请好友'
: panel === 'redeem'
? '填邀请码'
: '玩家社区';
const title = panel === 'invite' ? '邀请好友' : '玩家社区';
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
@@ -2004,7 +1989,7 @@ function ProfileReferralModal({
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
</div>
) : panel === 'invite' ? (
) : (
<div className="mt-5 space-y-3">
<div className="rounded-xl bg-zinc-50 px-4 py-4 text-center">
<div className="text-[11px] font-bold text-zinc-500">
@@ -2044,31 +2029,6 @@ function ProfileReferralModal({
</div>
</div>
</div>
) : (
<div className="mt-5 space-y-3">
{center?.hasRedeemedCode ? (
<div className="rounded-xl bg-emerald-50 px-4 py-4 text-center text-sm font-bold text-emerald-700">
</div>
) : (
<>
<input
value={inviteCodeInput}
onChange={(event) => onInputChange(event.target.value)}
placeholder="输入邀请码"
className="w-full rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-center text-base font-black tracking-[0.14em] outline-none focus:border-[#ff4056]"
/>
<button
type="button"
onClick={onSubmitRedeem}
disabled={isSubmitting || !inviteCodeInput.trim()}
className="w-full rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:opacity-60"
>
{isSubmitting ? '提交中' : '确认填写'}
</button>
</>
)}
</div>
)}
{error ? (
@@ -2246,10 +2206,8 @@ export function RpgEntryHomeView({
const [referralCenter, setReferralCenter] =
useState<ProfileReferralInviteCenterResponse | null>(null);
const [isLoadingReferral, setIsLoadingReferral] = useState(false);
const [isSubmittingReferral, setIsSubmittingReferral] = useState(false);
const [referralError, setReferralError] = useState<string | null>(null);
const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
const [inviteCodeInput, setInviteCodeInput] = useState('');
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
@@ -2616,30 +2574,6 @@ export function RpgEntryHomeView({
},
);
};
const submitReferralInviteCode = () => {
if (isSubmittingReferral || !inviteCodeInput.trim()) {
return;
}
setIsSubmittingReferral(true);
setReferralError(null);
setReferralSuccess(null);
void redeemRpgProfileReferralInviteCode(inviteCodeInput)
.then((response: RedeemProfileReferralInviteCodeResponse) => {
setReferralCenter(response.center);
setInviteCodeInput('');
setReferralSuccess(
response.inviteeRewardGranted ? '已获得30陶泥币' : '填写成功',
);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setReferralError(
error instanceof Error ? error.message : '填写邀请码失败',
);
})
.finally(() => setIsSubmittingReferral(false));
};
const openRewardCodeModal = () => {
setIsRewardCodeOpen(true);
setRewardCodeError(null);
@@ -3251,17 +3185,12 @@ export function RpgEntryHomeView({
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<SectionHeader title="常用功能" detail="快捷入口" />
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-2 gap-3">
<ProfileShortcutButton
label="邀请好友"
icon={UserPlus}
onClick={() => openProfilePopupPanel('invite')}
/>
<ProfileShortcutButton
label="填邀请码"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
<ProfileShortcutButton
label="玩家社区"
icon={MessageCircle}
@@ -3700,15 +3629,11 @@ export function RpgEntryHomeView({
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
inviteCodeInput={inviteCodeInput}
isLoading={isLoadingReferral}
isSubmitting={isSubmittingReferral}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onInputChange={setInviteCodeInput}
onCopyInvite={copyInviteInfo}
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
{rewardCodeModal}
@@ -3822,15 +3747,11 @@ export function RpgEntryHomeView({
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
inviteCodeInput={inviteCodeInput}
isLoading={isLoadingReferral}
isSubmitting={isSubmittingReferral}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onInputChange={setInviteCodeInput}
onCopyInvite={copyInviteInfo}
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
{isProfilePlayStatsOpen ? (

View File

@@ -219,15 +219,20 @@ describe('authService', () => {
},
});
const user = await loginWithPhoneCode('13800138000', '123456');
const response = await loginWithPhoneCode(
'13800138000',
'123456',
'spring-2026',
);
expect(user.username).toBe('138****8000');
expect(response.user.username).toBe('138****8000');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/login',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
code: '123456',
inviteCode: 'SPRING2026',
}),
}),
'登录失败',

View File

@@ -65,6 +65,13 @@ export function normalizePhoneInput(phoneInput: string) {
return phoneInput.replace(/[^\d+]/gu, '').trim();
}
export function normalizeInviteCodeInput(inviteCode: string | undefined) {
return (inviteCode ?? '')
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
}
export function getStoredLastLoginPhone() {
if (typeof window === 'undefined') {
return '';
@@ -145,7 +152,12 @@ export async function sendPhoneLoginCode(
return response;
}
export async function loginWithPhoneCode(phone: string, code: string) {
export async function loginWithPhoneCode(
phone: string,
code: string,
inviteCode?: string,
) {
const normalizedInviteCode = normalizeInviteCodeInput(inviteCode);
const response = await requestJson<AuthPhoneLoginResponse>(
'/api/auth/phone/login',
{
@@ -154,6 +166,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
body: JSON.stringify({
phone: normalizePhoneInput(phone),
code: code.trim(),
...(normalizedInviteCode ? { inviteCode: normalizedInviteCode } : {}),
}),
},
'登录失败',
@@ -161,7 +174,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
);
setStoredAccessToken(response.token, { emit: false });
return response.user;
return response;
}
export async function bindWechatPhone(phone: string, code: string) {